- DB: migrations/002_netup.sql — netup_settings + netup_room_mapping tables - Backend: routes/netup.ts — GET/PATCH settings, POST test, GET/POST room mappings, POST send TV message; notifyNetupCheckin/Checkout helpers - Backend: bookings PATCH — fire-and-forget NetUP check-in/out on status change - Frontend: TvWelcomePage — connection settings tab + room mapping tab - Frontend: sidebarItem added to tv-welcome module (shows in sidebar menu) - Frontend: ModulesPage Settings button navigates to /tv-welcome - Frontend: BookingModal — "Сообщение гостю на TV" panel for existing bookings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
971 lines
45 KiB
TypeScript
971 lines
45 KiB
TypeScript
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 { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
|
||
import type { Discount } from '../../pages/DiscountsPage'
|
||
import { Modal } from '../ui/Modal'
|
||
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
|
||
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
|
||
import { FloorMapModal } from '../floormap/FloorMapModal'
|
||
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', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
|
||
|
||
const ADDITIONAL_SERVICES = [
|
||
{ id: 'breakfast', label: 'Завтрак', price: 800 },
|
||
{ id: 'transfer', label: 'Трансфер', price: 2500 },
|
||
{ id: 'parking', label: 'Парковка', price: 500 },
|
||
{ id: 'laundry', label: 'Стирка', price: 600 },
|
||
{ id: 'minibar', label: 'Мини-бар', price: 1500 },
|
||
{ id: 'excursion', label: 'Экскурсия', price: 3000 },
|
||
]
|
||
|
||
interface AddedService { id: string; label: string; price: number; qty: number }
|
||
|
||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||
|
||
interface BookingModalProps {
|
||
open: boolean
|
||
draft: DraftBooking
|
||
rooms: Room[]
|
||
bookings?: Booking[]
|
||
onClose: () => void
|
||
onSave: (data: Partial<Booking>) => void
|
||
existing?: Booking
|
||
}
|
||
|
||
function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) {
|
||
if (!checkIn || !checkOut || checkIn >= checkOut) return false
|
||
return bookings.some(b =>
|
||
b.roomId === roomId &&
|
||
b.id !== excludeId &&
|
||
b.status !== 'cancelled' && b.status !== 'no_show' &&
|
||
b.checkIn < checkOut && b.checkOut > checkIn,
|
||
)
|
||
}
|
||
|
||
function availableRooms(rooms: Room[], bookings: Booking[], checkIn: string, checkOut: string, excludeId?: string) {
|
||
return rooms.filter(r => !isConflict(bookings, r.id, checkIn, checkOut, excludeId))
|
||
}
|
||
|
||
export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing }: BookingModalProps) {
|
||
const { statuses } = useModules()
|
||
const { user } = useAuth()
|
||
const tvEnabled = statuses['tv-welcome'] === 'active'
|
||
|
||
// TV message state
|
||
const [tvMsg, setTvMsg] = useState('')
|
||
const [tvSending, setTvSending] = useState(false)
|
||
const [tvSent, setTvSent] = useState(false)
|
||
const [tvError, setTvError] = useState('')
|
||
|
||
const handleSendTv = async () => {
|
||
if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return
|
||
setTvSending(true)
|
||
setTvError('')
|
||
setTvSent(false)
|
||
try {
|
||
await api.netup.sendMessage(user.hotelSlug, existing.roomId, tvMsg, existing.guestName)
|
||
setTvSent(true)
|
||
setTvMsg('')
|
||
setTimeout(() => setTvSent(false), 3000)
|
||
} catch (err) {
|
||
setTvError(err instanceof Error ? err.message : 'Ошибка отправки')
|
||
} finally {
|
||
setTvSending(false)
|
||
}
|
||
}
|
||
|
||
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 ?? '',
|
||
})
|
||
const [guestTags, setGuestTags] = useState<string[]>([])
|
||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'terminal' | null>(null)
|
||
const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0)
|
||
const [showFloorMap, setShowFloorMap] = useState(false)
|
||
const [addedServices, setAddedServices] = useState<AddedService[]>([])
|
||
const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id)
|
||
const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking')
|
||
const [passport, setPassport] = useState<PassportData>(EMPTY_PASSPORT)
|
||
const [selectedDiscountId, setSelectedDiscountId] = useState<string>('')
|
||
const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive)
|
||
const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId)
|
||
|
||
const setP = <K extends keyof PassportData>(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)
|
||
const [startHour, setStartHour] = useState(10)
|
||
const [endHour, setEndHour] = useState(12)
|
||
|
||
// Extra beds
|
||
const [extraBeds, setExtraBeds] = useState(0)
|
||
const EXTRA_BED_PRICE = 1500
|
||
|
||
const room = rooms.find(r => r.id === form.roomId)
|
||
const nights = form.checkIn && form.checkOut
|
||
? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000)
|
||
: 0
|
||
|
||
const hourlyHours = Math.max(0, endHour - startHour)
|
||
const roomBaseTotal = isHourly && room?.allowHourly
|
||
? (room.hourlyRate ?? 0) * hourlyHours
|
||
: (room?.baseRate ?? 0) * nights
|
||
const discountAmount = selectedDiscount
|
||
? selectedDiscount.valueType === 'percent'
|
||
? Math.round(roomBaseTotal * Math.min(100, selectedDiscount.value) / 100)
|
||
: Math.min(roomBaseTotal, selectedDiscount.value)
|
||
: 0
|
||
const roomTotal = Math.max(0, roomBaseTotal - discountAmount)
|
||
const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0)
|
||
const extraBedsTotal = extraBeds * EXTRA_BED_PRICE * (isHourly ? 1 : Math.max(1, nights))
|
||
const total = roomTotal + servicesTotal + extraBedsTotal
|
||
|
||
const debt = Math.max(0, total - paidAmount)
|
||
|
||
const addService = () => {
|
||
const svc = ADDITIONAL_SERVICES.find(s => s.id === selectedServiceId)
|
||
if (!svc) return
|
||
setAddedServices(prev => {
|
||
const ex = prev.find(s => s.id === svc.id)
|
||
if (ex) return prev.map(s => s.id === svc.id ? { ...s, qty: s.qty + 1 } : s)
|
||
return [...prev, { ...svc, qty: 1 }]
|
||
})
|
||
}
|
||
|
||
const removeService = (id: string) =>
|
||
setAddedServices(prev => prev.filter(s => s.id !== id))
|
||
const isFutureBooking = form.checkIn > format(new Date(), 'yyyy-MM-dd')
|
||
|
||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||
setForm(prev => ({ ...prev, [k]: v }))
|
||
|
||
const toggleTag = (tag: string) =>
|
||
setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
|
||
|
||
const handleSave = () => {
|
||
if (!form.guestName) return
|
||
if (isHourly && room?.allowHourly) {
|
||
if (!hourlyDate) return
|
||
} else {
|
||
if (!form.checkIn || !form.checkOut) return
|
||
}
|
||
|
||
const hourlyPrefix = isHourly && room?.allowHourly
|
||
? `[Почасово: ${String(startHour).padStart(2, '0')}:00–${String(endHour).padStart(2, '0')}:00] `
|
||
: ''
|
||
const extraBedsPrefix = extraBeds > 0 ? `[Доп.места: ${extraBeds}] ` : ''
|
||
|
||
const checkIn = isHourly && room?.allowHourly ? hourlyDate : form.checkIn
|
||
const checkOut = isHourly && room?.allowHourly ? hourlyDate : form.checkOut
|
||
|
||
onSave({
|
||
...form,
|
||
source: 'direct',
|
||
checkIn,
|
||
checkOut,
|
||
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'),
|
||
})
|
||
}
|
||
|
||
const showHourlyTab = room?.allowHourly === true
|
||
|
||
return (
|
||
<>
|
||
<Modal
|
||
open={open}
|
||
onClose={onClose}
|
||
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
|
||
size="2xl"
|
||
footer={
|
||
<>
|
||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||
<button onClick={handleSave} className="btn-primary" disabled={!form.guestName}>
|
||
{existing ? 'Сохранить' : 'Создать бронирование'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
{/* ── Tabs ── */}
|
||
<div className="flex border-b border-slate-200 dark:border-slate-700 mb-4 -mt-1">
|
||
{([
|
||
{ id: 'booking' as const, label: 'Бронирование', icon: CalendarDays },
|
||
{ id: 'passport' as const, label: 'Документы гостя', icon: IdCard,
|
||
badge: passportFilled },
|
||
]).map(t => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => setActiveTab(t.id)}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||
activeTab === t.id
|
||
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
|
||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
|
||
)}
|
||
>
|
||
<t.icon size={14} />
|
||
{t.label}
|
||
{t.badge && (
|
||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── BOOKING TAB ── */}
|
||
{activeTab === 'booking' && (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||
{/* ── LEFT COLUMN ── */}
|
||
<div className="flex-1 space-y-3 min-w-0">
|
||
{/* Room */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||
Номер
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowFloorMap(true)}
|
||
className="flex items-center gap-1 text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||
>
|
||
<Map size={12} />
|
||
Поэтажный план
|
||
</button>
|
||
</div>
|
||
<select
|
||
value={form.roomId}
|
||
onChange={e => set('roomId', e.target.value)}
|
||
className="input"
|
||
>
|
||
{rooms.map(r => (
|
||
<option key={r.id} value={r.id}>
|
||
{r.number} — {r.type} ({r.baseRate.toLocaleString('ru-RU')} ₽/ночь{r.allowHourly ? `, ${(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч` : ''})
|
||
</option>
|
||
))}
|
||
</select>
|
||
{/* Conflict warning */}
|
||
{!isHourly && form.roomId && form.checkIn && form.checkOut && form.checkIn < form.checkOut &&
|
||
isConflict(bookings, form.roomId, form.checkIn, form.checkOut, existing?.id) && (() => {
|
||
const free = availableRooms(rooms, bookings, form.checkIn, form.checkOut, existing?.id)
|
||
return (
|
||
<div className="mt-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700">
|
||
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-300 text-xs mb-2">
|
||
<AlertCircle size={13} className="shrink-0 mt-0.5" />
|
||
<span>Номер занят на эти даты.</span>
|
||
</div>
|
||
{free.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<span className="text-xs text-amber-700 dark:text-amber-400 self-center">Свободны:</span>
|
||
{free.map(r => (
|
||
<button
|
||
key={r.id}
|
||
type="button"
|
||
onClick={() => set('roomId', r.id)}
|
||
className="text-xs px-2 py-0.5 rounded-md bg-white dark:bg-slate-700 border border-amber-300 dark:border-amber-600 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30 font-medium transition-colors"
|
||
>
|
||
№{r.number} ({r.type})
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-amber-700 dark:text-amber-400">Нет свободных номеров на эти даты.</p>
|
||
)}
|
||
</div>
|
||
)
|
||
})()
|
||
}
|
||
</div>
|
||
|
||
{/* Guest name + email */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Имя гостя *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
placeholder="Иван Иванов"
|
||
value={form.guestName}
|
||
onChange={e => set('guestName', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
className="input"
|
||
placeholder="guest@example.com"
|
||
value={form.guestEmail}
|
||
onChange={e => set('guestEmail', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hourly / Daily toggle */}
|
||
{showHourlyTab && (
|
||
<div className="flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsHourly(false)}
|
||
className={cn(
|
||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||
!isHourly
|
||
? 'bg-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||
)}
|
||
>
|
||
Посуточно
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsHourly(true)}
|
||
className={cn(
|
||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||
isHourly
|
||
? 'bg-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||
)}
|
||
>
|
||
Почасово
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Dates */}
|
||
{isHourly && room?.allowHourly ? (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Дата *
|
||
</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={hourlyDate}
|
||
onChange={e => setHourlyDate(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Начало
|
||
</label>
|
||
<select
|
||
className="input"
|
||
value={startHour}
|
||
onChange={e => setStartHour(parseInt(e.target.value))}
|
||
>
|
||
{HOURS.map(h => (
|
||
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Конец
|
||
</label>
|
||
<select
|
||
className="input"
|
||
value={endHour}
|
||
onChange={e => setEndHour(parseInt(e.target.value))}
|
||
>
|
||
{HOURS.filter(h => h > startHour).map(h => (
|
||
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Заезд *
|
||
</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={form.checkIn}
|
||
onChange={e => set('checkIn', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Выезд *
|
||
</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={form.checkOut}
|
||
onChange={e => set('checkOut', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Adults + Children + Status */}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||
Взрослых
|
||
</label>
|
||
<input
|
||
type="number" min={1} max={room?.maxGuests ?? 6}
|
||
className="input"
|
||
value={form.adults}
|
||
onChange={e => set('adults', parseInt(e.target.value) || 1)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Детей
|
||
</label>
|
||
<input
|
||
type="number" min={0} max={4}
|
||
className="input"
|
||
value={form.children}
|
||
onChange={e => set('children', parseInt(e.target.value) || 0)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Статус
|
||
</label>
|
||
<select
|
||
className="input"
|
||
value={form.status}
|
||
onChange={e => set('status', e.target.value as BookingStatus)}
|
||
>
|
||
{(Object.entries(BOOKING_STATUS_LABELS) as [BookingStatus, string][]).map(([k, v]) => (
|
||
<option key={k} value={k}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* TV Message — только для существующих броней с активным модулем */}
|
||
{tvEnabled && existing && existing.roomId && (
|
||
<div className="rounded-xl border border-violet-200 dark:border-violet-800/60 bg-violet-50 dark:bg-violet-900/20 p-3 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Tv2 size={14} className="text-violet-600 dark:text-violet-400 shrink-0" />
|
||
<span className="text-sm font-medium text-violet-700 dark:text-violet-300">
|
||
Сообщение гостю на TV
|
||
</span>
|
||
</div>
|
||
<textarea
|
||
rows={2}
|
||
value={tvMsg}
|
||
onChange={e => setTvMsg(e.target.value)}
|
||
placeholder="Введите сообщение для гостя..."
|
||
className="w-full rounded-lg border border-violet-200 dark:border-violet-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 resize-none focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={handleSendTv}
|
||
disabled={tvSending || !tvMsg.trim()}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 hover:bg-violet-700 disabled:opacity-50 text-white text-xs font-medium transition-colors"
|
||
>
|
||
{tvSending
|
||
? <Loader2 size={12} className="animate-spin" />
|
||
: <Send size={12} />}
|
||
Отправить на TV
|
||
</button>
|
||
{tvSent && (
|
||
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||
<CheckCircle2 size={12} /> Отправлено
|
||
</span>
|
||
)}
|
||
{tvError && (
|
||
<span className="text-xs text-red-500">{tvError}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Extra beds */}
|
||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
|
||
<div className="flex items-center gap-2">
|
||
<BedDouble size={15} className="text-slate-400 shrink-0" />
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 leading-tight">Доп. места</p>
|
||
<p className="text-[10px] text-slate-400">{EXTRA_BED_PRICE.toLocaleString('ru-RU')} ₽/место{!isHourly && nights > 0 ? '/ночь' : ''}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{extraBeds > 0 && (
|
||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400 mr-1">
|
||
+{extraBedsTotal.toLocaleString('ru-RU')} ₽
|
||
</span>
|
||
)}
|
||
<button type="button" onClick={() => setExtraBeds(e => Math.max(0, e - 1))}
|
||
className="w-6 h-6 rounded-md bg-slate-200 dark:bg-slate-600 flex items-center justify-center hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors disabled:opacity-40"
|
||
disabled={extraBeds === 0}
|
||
>
|
||
<Minus size={11} />
|
||
</button>
|
||
<span className="w-5 text-center text-sm font-semibold text-slate-900 dark:text-slate-100">{extraBeds}</span>
|
||
<button type="button" onClick={() => setExtraBeds(e => Math.min(4, e + 1))}
|
||
className="w-6 h-6 rounded-md bg-slate-200 dark:bg-slate-600 flex items-center justify-center hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors disabled:opacity-40"
|
||
disabled={extraBeds >= 4}
|
||
>
|
||
<Plus size={11} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Guest tags */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Статус гостя
|
||
</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{GUEST_TAGS.map(tag => {
|
||
const isVip = tag === 'VIP'
|
||
const selected = guestTags.includes(tag)
|
||
return (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => toggleTag(tag)}
|
||
className={cn(
|
||
'flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
|
||
selected
|
||
? isVip
|
||
? 'bg-amber-500 border-amber-500 text-white'
|
||
: 'bg-brand-600 border-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
|
||
)}
|
||
>
|
||
{isVip && <Star size={10} className={selected ? 'text-white' : 'text-amber-500'} />}
|
||
{tag}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── RIGHT COLUMN ── */}
|
||
<div className="flex-1 space-y-3 min-w-0">
|
||
{/* Payment */}
|
||
{total > 0 && (
|
||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600">
|
||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Оплата</p>
|
||
</div>
|
||
<div className="p-4 space-y-3">
|
||
<div>
|
||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Способ оплаты</label>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPaymentMethod(p => p === 'cash' ? null : 'cash')}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||
paymentMethod === 'cash'
|
||
? 'bg-emerald-600 text-white border-emerald-600'
|
||
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-emerald-400',
|
||
)}
|
||
>
|
||
<Banknote size={14} />
|
||
Наличные
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPaymentMethod(p => p === 'terminal' ? null : 'terminal')}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||
paymentMethod === 'terminal'
|
||
? 'bg-blue-600 text-white border-blue-600'
|
||
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-blue-400',
|
||
)}
|
||
>
|
||
<CreditCard size={14} />
|
||
Терминал
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/* Discount */}
|
||
<div>
|
||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1">
|
||
<Tag size={11} /> Скидка
|
||
</label>
|
||
<select
|
||
className="input text-sm"
|
||
value={selectedDiscountId}
|
||
onChange={e => setSelectedDiscountId(e.target.value)}
|
||
>
|
||
<option value="">— Без скидки —</option>
|
||
{activeDiscounts.map(d => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name} ({d.value === 100 ? 'Бесплатно' : `−${d.value}${d.valueType === 'percent' ? '%' : ' ₽'}`})
|
||
</option>
|
||
))}
|
||
</select>
|
||
{selectedDiscount && (
|
||
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1">
|
||
−{discountAmount.toLocaleString('ru-RU')} ₽
|
||
{selectedDiscount.note ? ` · ${selectedDiscount.note}` : ''}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Additional services */}
|
||
<div>
|
||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Дополнительные услуги</label>
|
||
<div className="flex gap-2 mb-2">
|
||
<select
|
||
className="input flex-1 text-sm"
|
||
value={selectedServiceId}
|
||
onChange={e => setSelectedServiceId(e.target.value)}
|
||
>
|
||
{ADDITIONAL_SERVICES.map(s => (
|
||
<option key={s.id} value={s.id}>{s.label} — {s.price.toLocaleString('ru-RU')} ₽</option>
|
||
))}
|
||
</select>
|
||
<button type="button" onClick={addService} className="btn-secondary px-2">
|
||
<Plus size={14} />
|
||
</button>
|
||
</div>
|
||
{addedServices.length > 0 && (
|
||
<div className="space-y-1">
|
||
{addedServices.map(s => (
|
||
<div key={s.id} className="flex items-center justify-between text-xs px-2 py-1 rounded bg-slate-100 dark:bg-slate-700">
|
||
<span className="text-slate-700 dark:text-slate-300">{s.label} × {s.qty}</span>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-slate-900 dark:text-slate-100">{(s.price * s.qty).toLocaleString('ru-RU')} ₽</span>
|
||
<button type="button" onClick={() => removeService(s.id)} className="text-slate-400 hover:text-red-500">
|
||
<Trash2 size={11} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">
|
||
Оплачено (₽)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={total}
|
||
className="input w-40"
|
||
value={paidAmount}
|
||
onChange={e => setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── BOTTOM ROW: Notes + Summary ── */}
|
||
<div className="flex gap-6">
|
||
<div className="flex-1">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Примечания
|
||
</label>
|
||
<textarea
|
||
className="input resize-none"
|
||
rows={2}
|
||
placeholder="Дополнительные пожелания..."
|
||
value={form.notes}
|
||
onChange={e => set('notes', e.target.value)}
|
||
/>
|
||
</div>
|
||
{(total > 0 || discountAmount > 0) && room && (
|
||
<div className="flex-1 rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5 self-end">
|
||
{isHourly && room.allowHourly ? (
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽
|
||
</span>
|
||
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
|
||
{roomBaseTotal.toLocaleString('ru-RU')} ₽
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
||
</span>
|
||
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
|
||
{roomBaseTotal.toLocaleString('ru-RU')} ₽
|
||
</span>
|
||
</div>
|
||
)}
|
||
{discountAmount > 0 && selectedDiscount && (
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span className="text-emerald-600 dark:text-emerald-400">
|
||
{selectedDiscount.value === 100 ? 'Бесплатное проживание' : selectedDiscount.name}
|
||
</span>
|
||
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
|
||
−{discountAmount.toLocaleString('ru-RU')} ₽
|
||
</span>
|
||
</div>
|
||
)}
|
||
{servicesTotal > 0 && (
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span className="text-slate-600 dark:text-slate-300">Доп. услуги</span>
|
||
<span className="font-semibold text-slate-900 dark:text-slate-100">{servicesTotal.toLocaleString('ru-RU')} ₽</span>
|
||
</div>
|
||
)}
|
||
{(servicesTotal > 0 || discountAmount > 0) && (
|
||
<div className="flex items-center justify-between border-t border-slate-200 dark:border-slate-600 pt-1.5">
|
||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">Итого</span>
|
||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{total.toLocaleString('ru-RU')} ₽</span>
|
||
</div>
|
||
)}
|
||
{servicesTotal === 0 && discountAmount === 0 && (
|
||
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 text-right">{total.toLocaleString('ru-RU')} ₽</div>
|
||
)}
|
||
{paidAmount > 0 && (
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span className="text-emerald-600 dark:text-emerald-400">Оплачено</span>
|
||
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
|
||
{paidAmount.toLocaleString('ru-RU')} ₽
|
||
</span>
|
||
</div>
|
||
)}
|
||
{debt > 0 && (
|
||
<div className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||
<AlertCircle size={13} />
|
||
<span className="flex-1">{isFutureBooking ? 'Задолженность' : 'Долг при заселении'}</span>
|
||
<span className="font-semibold">{debt.toLocaleString('ru-RU')} ₽</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)} {/* end booking tab */}
|
||
|
||
{/* ── PASSPORT TAB ── */}
|
||
{activeTab === 'passport' && (
|
||
<div className="space-y-4">
|
||
{/* Document type + scan */}
|
||
<div className="flex items-center justify-between gap-3">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Тип документа</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => alert('Для сканирования подключите RFID/OCR сканер паспорта')}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-dashed border-brand-300 dark:border-brand-600 text-brand-600 dark:text-brand-400 text-xs font-medium hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors shrink-0"
|
||
>
|
||
<ScanLine size={13} /> Сканировать
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<div className="h-0" />
|
||
<div className="flex gap-2">
|
||
{DOC_TYPES.map(dt => (
|
||
<button
|
||
key={dt.id}
|
||
type="button"
|
||
onClick={() => setP('docType', dt.id)}
|
||
className={cn(
|
||
'flex-1 py-2 rounded-lg text-sm font-medium border transition-colors',
|
||
passport.docType === dt.id
|
||
? 'bg-brand-600 border-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-400',
|
||
)}
|
||
>
|
||
{dt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Name fields */}
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Фамилия</label>
|
||
<input type="text" className="input" placeholder="Иванов"
|
||
value={passport.lastName} onChange={e => setP('lastName', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя</label>
|
||
<input type="text" className="input" placeholder="Иван"
|
||
value={passport.firstName} onChange={e => setP('firstName', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Отчество</label>
|
||
<input type="text" className="input" placeholder="Иванович"
|
||
value={passport.patronymic} onChange={e => setP('patronymic', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Birth + nationality */}
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Дата рождения</label>
|
||
<input type="date" className="input"
|
||
value={passport.birthDate} onChange={e => setP('birthDate', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Место рождения</label>
|
||
<input type="text" className="input" placeholder="г. Москва"
|
||
value={passport.birthPlace} onChange={e => setP('birthPlace', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Гражданство</label>
|
||
<input type="text" className="input" placeholder="Россия"
|
||
value={passport.nationality} onChange={e => setP('nationality', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Document fields */}
|
||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||
<div className="px-4 py-2.5 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600">
|
||
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Реквизиты документа</p>
|
||
</div>
|
||
<div className="p-4 space-y-3">
|
||
{passport.docType === 'rf_passport' && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Серия</label>
|
||
<input
|
||
type="text" className="input" placeholder="45 16" maxLength={5}
|
||
value={passport.series} onChange={e => setP('series', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Номер</label>
|
||
<input
|
||
type="text" className="input" placeholder="123456" maxLength={6}
|
||
value={passport.number} onChange={e => setP('number', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{passport.docType !== 'rf_passport' && (
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Номер документа</label>
|
||
<input
|
||
type="text" className="input" placeholder="Номер документа"
|
||
value={passport.number} onChange={e => setP('number', e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Кем выдан</label>
|
||
<input
|
||
type="text" className="input" placeholder="ОУМВД России по г. Москве"
|
||
value={passport.issuedBy} onChange={e => setP('issuedBy', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Дата выдачи</label>
|
||
<input type="date" className="input"
|
||
value={passport.issuedDate} onChange={e => setP('issuedDate', e.target.value)} />
|
||
</div>
|
||
{passport.docType === 'rf_passport' && (
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Код подразделения</label>
|
||
<input
|
||
type="text" className="input" placeholder="770-001" maxLength={7}
|
||
value={passport.divisionCode} onChange={e => setP('divisionCode', e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Registration address */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Адрес регистрации</label>
|
||
<input
|
||
type="text" className="input"
|
||
placeholder="г. Москва, ул. Примерная, д. 1, кв. 1"
|
||
value={passport.registrationAddress}
|
||
onChange={e => setP('registrationAddress', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Hint */}
|
||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||
Данные используются для формирования регистрационной карты и уведомления УМВД (при необходимости)
|
||
</p>
|
||
</div>
|
||
)} {/* end passport tab */}
|
||
|
||
</Modal>
|
||
|
||
{showFloorMap && (
|
||
<FloorMapModal
|
||
rooms={rooms}
|
||
selectedRoomId={form.roomId}
|
||
onSelectRoom={(id) => { set('roomId', id); setShowFloorMap(false) }}
|
||
onClose={() => setShowFloorMap(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|