Files
hotelsync/src/components/bookings/BookingModal.tsx
HotelSync 588ecb0f2a feat: hourly pricing + category base prices in booking modal + availability grid
- Add hourly_price column to rate_overrides, base_price/allow_hourly/hourly_base_price to room_categories (migration 033)
- Update categories and rate-overrides backend routes to handle new fields
- AvailabilityPage: hourly/nightly mode toggle, period prices auto-applied to grid with 'П' indicator, confirmation dialog when overriding period prices
- BookingModal: nightly/hourly toggle at top, pricing hierarchy (override → category base → room rate)
- RoomCategoriesPage: base_price/allow_hourly/hourly_base_price fields in category form; inline rooms panel
- CalendarPage + BookingCalendar: pass hourlyPriceOverrides and categoryBasePrices down to BookingModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 00:05:14 +03:00

1353 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useLayoutEffect, useRef, useEffect } from 'react'
import { format, addDays, parseISO } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2, Clock, CalendarDays, User, Phone } 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 type { RentalObject, RentalBooking } from '../../data/rentalData'
import { FloorMapModal } from '../floormap/FloorMapModal'
import { useModules } from '../../contexts/ModulesContext'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
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
/** categoryId → date (YYYY-MM-DD) → price; used to price bookings by availability rates */
priceOverrides?: Record<string, Record<string, number>>
/** categoryId → date (YYYY-MM-DD) → hourlyPrice; per-date hourly rate overrides */
hourlyPriceOverrides?: Record<string, Record<string, number>>
/** categoryId → { nightlyBase, hourlyBase } from category settings */
categoryBasePrices?: Record<string, { nightlyBase: number; hourlyBase: number }>
rooms: Room[]
bookings?: Booking[]
onClose: () => void
onSave: (data: Partial<Booking>) => void
existing?: Booking
// Rental tab (only shown when these are provided AND not editing existing booking)
rentalObjects?: RentalObject[]
rentalBookings?: RentalBooking[]
onRentalSave?: (b: RentalBooking) => void
slug?: string
}
// ── Rental time helpers ────────────────────────────────────────────────────────
function rentalTimeOptions(openHour: number, closeHour: number): number[] {
const result: number[] = []
for (let m = openHour * 60; m < closeHour * 60; m += 15) result.push(m)
return result
}
function rentalEndOptions(fromMin: number, toMin: number): number[] {
const result: number[] = []
for (let m = fromMin + 15; m <= toMin; m += 15) result.push(m)
return result
}
function fmtTime(min: number) {
const h = Math.floor(min / 60)
const m = min % 60
return m === 0 ? `${h}:00` : `${h}:${String(m).padStart(2, '0')}`
}
function parseFullName(full: string) {
const parts = full.trim().split(/\s+/)
return {
lastName: parts[0] ?? '',
firstName: parts[1] ?? '',
middleName: parts.slice(2).join(' '),
}
}
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.status !== 'checked_out' &&
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))
}
// Возвращает уникальные категории (типы) номеров с количеством свободных
function getRoomCategories(rooms: Room[], bookings: Booking[], checkIn: string, checkOut: string, excludeId?: string) {
const types = Array.from(new Set(rooms.map(r => r.type)))
return types.map(type => {
const roomsOfType = rooms.filter(r => r.type === type)
const freeRooms = roomsOfType.filter(r => !isConflict(bookings, r.id, checkIn, checkOut, excludeId))
const minRate = Math.min(...roomsOfType.map(r => r.baseRate))
return { type, total: roomsOfType.length, free: freeRooms.length, minRate, rooms: freeRooms, allRooms: roomsOfType }
})
}
export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing, rentalObjects, rentalBookings = [], onRentalSave, slug, priceOverrides, hourlyPriceOverrides, categoryBasePrices }: BookingModalProps) {
const { statuses } = useModules()
const { user } = useAuth()
const tvEnabled = statuses['tv-welcome'] === 'active'
const showRentalTab = !existing && !!rentalObjects && rentalObjects.length > 0
// ── Rental tab state ────────────────────────────────────────────────────────
const [bookingType, setBookingType] = useState<'room' | 'rental'>('room')
const [rentalObjId, setRentalObjId] = useState(rentalObjects?.[0]?.id ?? '')
const [rentalDate, setRentalDate] = useState(draft.checkIn)
const [rentalIsFullDay, setRentalIsFullDay] = useState(false)
const [rentalStartM, setRentalStartM] = useState(0) // minutes since midnight
const [rentalEndM, setRentalEndM] = useState(60)
const [rentalGuest, setRentalGuest] = useState('')
const [rentalPhone, setRentalPhone] = useState('')
const [rentalNotes, setRentalNotes] = useState('')
const [rentalSaving, setRentalSaving] = useState(false)
const [rentalPayMethod, setRentalPayMethod] = useState<'cash' | 'terminal' | null>(null)
const [rentalPaidAmount, setRentalPaidAmount] = useState(0)
// Rental guest autocomplete
const [rentalShowSugg, setRentalShowSugg] = useState(false)
const [rentalDbResults, setRentalDbResults] = useState<{ id: string; firstName: string; lastName: string; middleName: string | null; phone: string | null }[]>([])
const rentalSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const rentalNameRef = useRef<HTMLDivElement>(null)
// Guest autocomplete for room tab
const [guestFullName, setGuestFullName] = useState(existing?.guestName ?? '')
const [showGuestSugg, setShowGuestSugg] = useState(false)
const [guestDbResults, setGuestDbResults] = useState<{ id: string; firstName: string; lastName: string; middleName: string | null; phone: string | null; email: string | null }[]>([])
const guestSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const guestNameRef = useRef<HTMLDivElement>(null)
const rentalObj = rentalObjects?.find(o => o.id === rentalObjId) ?? rentalObjects?.[0]
const rentalDayBookings = rentalBookings.filter(b =>
b.objectId === rentalObjId &&
(b.date as string).slice(0, 10) === rentalDate &&
b.status !== 'cancelled',
)
const rentalBufMin = rentalObj?.bufferMinutes ?? 0
const rentalAvailStarts = rentalObj
? rentalTimeOptions(rentalObj.openHour, rentalObj.closeHour).filter(m =>
!rentalDayBookings.some(b => !b.isFullDay && m >= b.startHour && m < b.endHour + rentalBufMin),
)
: []
const rentalGetMaxEnd = (start: number) => {
const next = rentalDayBookings.filter(b => !b.isFullDay && b.startHour >= start + 15)
.sort((a, b) => a.startHour - b.startHour)[0]
return next ? next.startHour - rentalBufMin : (rentalObj?.closeHour ?? 22) * 60
}
const rentalEffStart = rentalAvailStarts.includes(rentalStartM) ? rentalStartM : (rentalAvailStarts[0] ?? (rentalObj?.openHour ?? 8) * 60)
const rentalAvailEnds = rentalEndOptions(rentalEffStart, rentalGetMaxEnd(rentalEffStart))
// Sync rentalEndM when rentalEffStart shifts past it
useLayoutEffect(() => {
if (!rentalIsFullDay && rentalEndM <= rentalEffStart) {
setRentalEndM(rentalEffStart + 60)
}
}, [rentalEffStart])
// Close suggestions on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (rentalNameRef.current && !rentalNameRef.current.contains(e.target as Node)) {
setRentalShowSugg(false)
}
if (guestNameRef.current && !guestNameRef.current.contains(e.target as Node)) {
setShowGuestSugg(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
// Guest suggestions (room tab)
const guestSugg = [
...(bookings ?? []).filter(b => b.status === 'checked_in'),
...(bookings ?? []).filter(b => b.status === 'confirmed'),
]
const guestFiltered = guestFullName.trim().length > 0
? guestSugg.filter(b => b.guestName.toLowerCase().includes(guestFullName.toLowerCase()))
: guestSugg
// Rental guest suggestions from current bookings
const rentalGuestSugg = [
...(bookings ?? []).filter(b => b.status === 'checked_in'),
...(bookings ?? []).filter(b => b.status === 'confirmed'),
]
const rentalFiltered = rentalGuest.trim().length > 0
? rentalGuestSugg.filter(b => b.guestName.toLowerCase().includes(rentalGuest.toLowerCase()))
: rentalGuestSugg
const rentalHasFullDay = rentalDayBookings.some(b => b.isFullDay)
const rentalHasTimed = rentalDayBookings.some(b => !b.isFullDay)
const rentalNoSlots = !rentalHasFullDay && rentalAvailStarts.length === 0
const rentalHours = rentalIsFullDay
? (rentalObj ? rentalObj.closeHour - rentalObj.openHour : 0)
: Math.max(0, (rentalEndM - rentalEffStart) / 60)
const rentalTotal = rentalObj
? (rentalIsFullDay ? rentalObj.pricePerDay : rentalHours * rentalObj.pricePerHour)
: 0
const handleRentalSave = async () => {
if (!rentalObj || !rentalGuest.trim() || rentalSaving) return
setRentalSaving(true)
try {
if (slug) {
const { lastName: rLast, firstName: rFirst, middleName: rMid } = parseFullName(rentalGuest)
await api.guests.create(slug, {
last_name: rLast, first_name: rFirst,
middle_name: rMid || undefined,
phone: rentalPhone.trim() || undefined,
}).catch(() => { /* ignore duplicate */ })
}
onRentalSave?.({
id: `rb-${Date.now()}`,
objectId: rentalObj.id,
date: rentalDate,
isFullDay: rentalIsFullDay,
startHour: rentalIsFullDay ? rentalObj.openHour * 60 : rentalEffStart,
endHour: rentalIsFullDay ? rentalObj.closeHour * 60 : rentalEndM,
guestName: rentalGuest.trim(),
guestPhone: rentalPhone.trim(),
notes: rentalNotes.trim() || undefined,
totalAmount: rentalTotal,
paidAmount: rentalPaidAmount,
status: 'confirmed',
})
onClose()
} finally {
setRentalSaving(false)
}
}
// 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 _nameParts = (existing?.guestName ?? '').split(' ')
// Определяем начальную категорию из существующего бронирования или первого доступного номера
const _initRoomId = existing?.roomId ?? draft.roomId
const _initCategory = rooms.find(r => r.id === _initRoomId)?.type ?? rooms[0]?.type ?? ''
const [selectedCategory, setSelectedCategory] = useState(_initCategory)
const [pickSpecificRoom, setPickSpecificRoom] = useState(!!existing?.roomId)
const [form, setForm] = useState({
roomId: _initRoomId,
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<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 [selectedDiscountId, setSelectedDiscountId] = useState<string>('')
const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive)
const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId)
// 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 categories = getRoomCategories(rooms, bookings, form.checkIn, form.checkOut, existing?.id)
const currentCategory = categories.find(c => c.type === selectedCategory)
const categoryAvailableRooms = currentCategory
? (pickSpecificRoom ? currentCategory.rooms : currentCategory.allRooms)
: []
// При смене категории или дат (если не выбран конкретный номер) — авто-выбираем первый свободный
const handleCategorySelect = (type: string) => {
setSelectedCategory(type)
if (!pickSpecificRoom) {
const cat = getRoomCategories(rooms, bookings, form.checkIn, form.checkOut, existing?.id).find(c => c.type === type)
const first = cat?.rooms[0] ?? cat?.allRooms[0]
if (first) set('roomId', first.id)
}
}
const handlePickSpecificToggle = (on: boolean) => {
setPickSpecificRoom(on)
if (!on) {
// Возвращаемся к первому свободному в категории
const cat = getRoomCategories(rooms, bookings, form.checkIn, form.checkOut, existing?.id).find(c => c.type === selectedCategory)
const first = cat?.rooms[0] ?? cat?.allRooms[0]
if (first) set('roomId', first.id)
}
}
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)
// Calculate room cost: priority = availability override → category base price → room base rate
const nightCount = Math.floor(nights)
const roomNightlyTotal = (() => {
if (!room || !form.checkIn || nightCount <= 0) return 0
const catId = room.categoryId
const catOverrides = catId ? priceOverrides?.[catId] : undefined
const catBase = catId ? categoryBasePrices?.[catId]?.nightlyBase : undefined
// room.baseRate is NUMERIC in DB — pg returns it as a string; always coerce
const fallback = (catBase && catBase > 0) ? catBase : (Number(room.baseRate) || 0)
if (!catOverrides) return fallback * nightCount
let sum = 0
for (let i = 0; i < nightCount; i++) {
const d = format(addDays(parseISO(form.checkIn), i), 'yyyy-MM-dd')
const p = Number(catOverrides[d])
sum += (isFinite(p) && p > 0) ? p : fallback
}
return sum
})()
const hourlyRateForDate = (() => {
if (!room?.allowHourly) return 0
const catId = room.categoryId
const hourlyOverride = catId ? hourlyPriceOverrides?.[catId]?.[hourlyDate] : undefined
if (hourlyOverride && hourlyOverride > 0) return hourlyOverride
const catHourlyBase = catId ? categoryBasePrices?.[catId]?.hourlyBase : undefined
if (catHourlyBase && catHourlyBase > 0) return catHourlyBase
return room.hourlyRate ?? 0
})()
const roomBaseTotal = isHourly && room?.allowHourly
? hourlyRateForDate * hourlyHours
: roomNightlyTotal
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.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] `
: ''
const extraBedsPrefix = extraBeds > 0 ? `[Доп.места: ${extraBeds}] ` : ''
const checkIn = isHourly && room?.allowHourly ? hourlyDate : form.checkIn
const checkOut = isHourly && room?.allowHourly ? hourlyDate : form.checkOut
onSave({
roomId: form.roomId,
guestName,
guestEmail: form.guestEmail,
guestPhone: form.phone,
checkIn,
checkOut,
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',
})
}
const showHourlyTab = room?.allowHourly === true
return (
<>
<Modal
open={open}
onClose={onClose}
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
size="2xl"
footer={
bookingType === 'rental' ? (
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={handleRentalSave}
className="btn-primary"
disabled={!rentalGuest.trim() || rentalSaving || rentalHasFullDay || rentalNoSlots || (!rentalIsFullDay && rentalHours <= 0)}
>
{rentalSaving ? <Loader2 size={15} className="animate-spin" /> : null}
Забронировать аренду
</button>
</>
) : (
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!form.lastName || !form.firstName}>
{existing ? 'Сохранить' : 'Создать бронирование'}
</button>
</>
)
}
>
<div className="space-y-4">
{/* Rental / Room tabs */}
{showRentalTab && (
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl">
<button
onClick={() => setBookingType('room')}
className={cn(
'flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors',
bookingType === 'room'
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
Номер
</button>
<button
onClick={() => setBookingType('rental')}
className={cn(
'flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors',
bookingType === 'rental'
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
Аренда объекта
</button>
</div>
)}
{/* ── RENTAL FORM ── */}
{bookingType === 'rental' && rentalObj && (
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
{/* ── LEFT: объект + дата + время ── */}
<div className="flex-1 space-y-4 min-w-0">
{/* Object picker */}
{rentalObjects && rentalObjects.length > 1 && (
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Объект аренды</label>
<div className="grid grid-cols-2 gap-2">
{rentalObjects.map(o => (
<button
key={o.id}
type="button"
onClick={() => { setRentalObjId(o.id); setRentalStartM(o.openHour * 60); setRentalEndM(o.openHour * 60 + 60) }}
className={cn(
'flex items-center gap-2 p-2.5 rounded-xl border text-sm font-medium transition-colors text-left',
rentalObjId === o.id
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-300 dark:hover:border-brand-600',
)}
>
<span className="text-lg">{o.icon}</span>
<div className="min-w-0">
<p className="truncate">{o.name}</p>
<p className="text-xs text-slate-400">{o.pricePerHour} /ч</p>
</div>
</button>
))}
</div>
</div>
)}
{rentalObjects?.length === 1 && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40">
<span className="text-2xl">{rentalObj.icon}</span>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{rentalObj.name}</p>
<p className="text-xs text-slate-500">{rentalObj.pricePerHour} /ч · {rentalObj.pricePerDay} /день</p>
</div>
</div>
)}
{/* Date */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
<input type="date" className="input" value={rentalDate} onChange={e => setRentalDate(e.target.value)} />
</div>
{/* Full day / time */}
{rentalHasFullDay ? (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} className="shrink-0" />
<span>Объект уже забронирован на весь день</span>
</div>
) : (
<>
<div className={cn('flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600', rentalHasTimed && 'opacity-50 pointer-events-none')}>
<CalendarDays size={16} className="text-slate-400 shrink-0" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span>
{rentalHasTimed && <span className="text-xs text-slate-400">Есть частичные брони</span>}
<button
type="button"
onClick={() => !rentalHasTimed && setRentalIsFullDay(v => !v)}
className={cn('relative rounded-full overflow-hidden transition-colors shrink-0 p-0', rentalIsFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
style={{ height: 22, width: 40 }}
>
<span className={cn('absolute top-[3px] left-[2px] w-4 h-4 rounded-full bg-white shadow transition-transform duration-200', rentalIsFullDay ? 'translate-x-[18px]' : 'translate-x-0')} />
</button>
</div>
{!rentalIsFullDay && (
rentalNoSlots ? (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} className="shrink-0" />
<span>Все слоты на этот день заняты</span>
</div>
) : (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Время
{rentalBufMin > 0 && <span className="ml-1 font-normal normal-case">(перерыв {rentalBufMin} мин)</span>}
</label>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Начало</label>
<select className="input text-sm" value={rentalEffStart}
onChange={e => {
const v = parseInt(e.target.value)
setRentalStartM(v)
const maxE = rentalGetMaxEnd(v)
if (rentalEndM <= v || rentalEndM > maxE) setRentalEndM(Math.min(v + 60, maxE))
}}>
{rentalAvailStarts.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
</select>
</div>
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Конец</label>
<select className="input text-sm"
value={rentalAvailEnds.includes(rentalEndM) ? rentalEndM : (rentalAvailEnds[0] ?? rentalEndM)}
onChange={e => setRentalEndM(parseInt(e.target.value))}>
{rentalAvailEnds.map(m => <option key={m} value={m}>{fmtTime(m)}</option>)}
</select>
</div>
</div>
</div>
)
)}
</>
)}
</div>
{/* ── RIGHT: гость + оплата ── */}
<div className="w-full sm:w-72 space-y-4 shrink-0">
{/* Guest */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
<User size={13} className="inline mr-1" />Гость *
</label>
<div className="relative" ref={rentalNameRef}>
<input type="text" className="input" placeholder="Фамилия Имя Отчество" autoComplete="off"
value={rentalGuest}
onFocus={() => setRentalShowSugg(true)}
onChange={e => {
const val = e.target.value
setRentalGuest(val)
setRentalShowSugg(true)
if (rentalSearchTimer.current) clearTimeout(rentalSearchTimer.current)
if (val.trim().length >= 2 && slug) {
rentalSearchTimer.current = setTimeout(async () => {
try {
const results = await api.guests.list(slug, val.trim())
setRentalDbResults(results.slice(0, 8))
} catch { setRentalDbResults([]) }
}, 300)
} else {
setRentalDbResults([])
}
}}
/>
{rentalShowSugg && (rentalFiltered.length > 0 || rentalDbResults.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-600 shadow-xl overflow-hidden max-h-52 overflow-y-auto">
{rentalFiltered.slice(0, 5).map(b => (
<button key={b.id} type="button"
onMouseDown={() => { setRentalGuest(b.guestName); setRentalPhone(b.guestPhone ?? ''); setRentalShowSugg(false); setRentalDbResults([]) }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className={cn('w-2 h-2 rounded-full shrink-0', b.status === 'checked_in' ? 'bg-emerald-500' : 'bg-sky-400')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{b.guestName}</p>
<p className="text-[11px] text-slate-400 truncate">{b.guestPhone || (b.status === 'checked_in' ? '🏠 Проживает' : '📅 Бронь')}</p>
</div>
</button>
))}
{rentalDbResults
.filter(g => !rentalFiltered.some(b => b.guestName.startsWith(g.lastName)))
.map(g => (
<button key={g.id} type="button"
onMouseDown={() => { setRentalGuest([g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')); setRentalPhone(g.phone ?? ''); setRentalShowSugg(false); setRentalDbResults([]) }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{[g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')}</p>
<p className="text-[11px] text-slate-400 truncate">{g.phone ?? 'нет телефона'}</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Phone */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
<Phone size={13} className="inline mr-1" />Телефон
</label>
<input type="tel" className="input" placeholder="+7 (999) 000-00-00"
value={rentalPhone} onChange={e => setRentalPhone(e.target.value)} />
</div>
{/* Notes */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Примечания</label>
<textarea className="input resize-none h-16 text-sm" placeholder="Доп. пожелания..."
value={rentalNotes} onChange={e => setRentalNotes(e.target.value)} />
</div>
{/* Payment + Total */}
{rentalHours > 0 && (
<div className="rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/40 p-3 space-y-3">
{/* Итого */}
<div className="flex items-baseline justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400">
{rentalIsFullDay ? 'Весь день' : `${rentalHours} ч × ${rentalObj.pricePerHour.toLocaleString('ru-RU')}`}
</span>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
{rentalTotal.toLocaleString('ru-RU')}
</span>
</div>
{/* Способ оплаты */}
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Внесение оплаты</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setRentalPayMethod(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 flex-1 justify-center',
rentalPayMethod === '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={() => setRentalPayMethod(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 flex-1 justify-center',
rentalPayMethod === '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>
{/* Оплачено */}
{rentalPayMethod && (
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 dark:text-slate-400 shrink-0">Оплачено ()</label>
<input
type="number" min={0} max={rentalTotal}
className="input flex-1 text-sm"
value={rentalPaidAmount}
onChange={e => setRentalPaidAmount(Math.min(rentalTotal, Math.max(0, parseInt(e.target.value) || 0)))}
/>
<button
type="button"
onClick={() => setRentalPaidAmount(rentalTotal)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline shrink-0"
>
Всю сумму
</button>
</div>
)}
{rentalPayMethod && rentalPaidAmount > 0 && rentalPaidAmount < rentalTotal && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Долг: {(rentalTotal - rentalPaidAmount).toLocaleString('ru-RU')}
</p>
)}
</div>
)}
</div>
</div>
)}
{/* ── ROOM FORM ── */}
{bookingType === 'room' && (<>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
{/* ── LEFT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0">
{/* Почасово / посуточно — только если у выбранного номера есть почасовая ставка */}
{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>
)}
{/* 1. Даты заезда / выезда */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Даты проживания
</label>
{isHourly && room?.allowHourly ? (
<div className="space-y-3">
<input
type="date"
className="input"
value={hourlyDate}
onChange={e => setHourlyDate(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Начало</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-xs text-slate-500 dark:text-slate-400 mb-1">Конец</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-xs text-slate-500 dark:text-slate-400 mb-1">Заезд *</label>
<input
type="date" className="input"
value={form.checkIn}
onChange={e => {
const newCheckIn = e.target.value
set('checkIn', newCheckIn)
if (newCheckIn && (!form.checkOut || form.checkOut <= newCheckIn)) {
set('checkOut', format(addDays(new Date(newCheckIn), 1), 'yyyy-MM-dd'))
}
}}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Выезд *</label>
<input
type="date" className="input"
value={form.checkOut}
onChange={e => set('checkOut', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* 2. Категория номера */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold 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>
<div className="grid grid-cols-2 gap-2">
{categories.map(cat => {
const isSelected = selectedCategory === cat.type
const hasAvailable = cat.free > 0
const datesSet = !!(form.checkIn && form.checkOut && form.checkIn < form.checkOut)
return (
<button
key={cat.type}
type="button"
onClick={() => handleCategorySelect(cat.type)}
className={cn(
'relative text-left px-3 py-2.5 rounded-xl border-2 transition-all',
isSelected
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: hasAvailable || !datesSet
? 'border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700/40 hover:border-brand-300 dark:hover:border-brand-600'
: 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 opacity-60 cursor-default',
)}
>
<p className={cn(
'text-sm font-medium leading-tight',
isSelected ? 'text-brand-700 dark:text-brand-300' : 'text-slate-700 dark:text-slate-300',
!hasAvailable && datesSet && 'text-slate-400 dark:text-slate-500',
)}>
{cat.type}
</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500 mt-0.5">
от {cat.minRate.toLocaleString('ru-RU')} /ночь
</p>
{datesSet && (
<span className={cn(
'absolute top-2 right-2 text-[10px] font-semibold px-1.5 py-0.5 rounded-full',
hasAvailable
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400'
: 'bg-slate-100 dark:bg-slate-700 text-slate-400',
)}>
{hasAvailable ? `${cat.free} св.` : 'занято'}
</span>
)}
</button>
)
})}
</div>
{/* Переключатель конкретного номера */}
{selectedCategory && (
<div className="mt-3 flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer select-none">
<div
role="switch"
aria-checked={pickSpecificRoom}
onClick={() => handlePickSpecificToggle(!pickSpecificRoom)}
className={cn(
'relative w-9 h-5 rounded-full transition-colors',
pickSpecificRoom ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
)}
>
<span className={cn(
'absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform',
pickSpecificRoom && 'translate-x-4',
)} />
</div>
<span className="text-sm text-slate-600 dark:text-slate-400">Выбрать конкретный номер</span>
</label>
</div>
)}
{/* Выпадающий список конкретных номеров */}
{pickSpecificRoom && selectedCategory && (
<div className="mt-2">
{categoryAvailableRooms.length > 0 ? (
<select
value={form.roomId}
onChange={e => set('roomId', e.target.value)}
className="input"
>
{categoryAvailableRooms.map(r => (
<option key={r.id} value={r.id}>
{r.number}{r.floor ? `, ${r.floor} эт.` : ''} {r.baseRate.toLocaleString('ru-RU')} /ночь
{r.allowHourly ? ` · ${(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч` : ''}
</option>
))}
</select>
) : (
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-xs text-amber-700 dark:text-amber-300">
<AlertCircle size={13} className="shrink-0" />
Нет свободных номеров в этой категории на выбранные даты
</div>
)}
</div>
)}
</div>
</div>
{/* ── RIGHT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0">
{/* Гость */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Гость
</label>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">ФИО *</label>
<div className="relative" ref={guestNameRef}>
<input
type="text" className="input" placeholder="Иванов Иван Иванович" autoComplete="off"
value={guestFullName}
onFocus={() => setShowGuestSugg(true)}
onChange={e => {
const val = e.target.value
setGuestFullName(val)
const parsed = parseFullName(val)
set('lastName', parsed.lastName)
set('firstName', parsed.firstName)
set('middleName', parsed.middleName)
setShowGuestSugg(true)
if (guestSearchTimer.current) clearTimeout(guestSearchTimer.current)
if (val.trim().length >= 2 && slug) {
guestSearchTimer.current = setTimeout(async () => {
try {
const results = await api.guests.list(slug, val.trim())
setGuestDbResults(results.slice(0, 8))
} catch { setGuestDbResults([]) }
}, 300)
} else {
setGuestDbResults([])
}
}}
/>
{showGuestSugg && (guestFiltered.length > 0 || guestDbResults.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-600 shadow-xl overflow-hidden max-h-52 overflow-y-auto">
{guestFiltered.slice(0, 5).map(b => (
<button key={b.id} type="button"
onMouseDown={() => {
setGuestFullName(b.guestName)
const p = parseFullName(b.guestName)
set('lastName', p.lastName); set('firstName', p.firstName); set('middleName', p.middleName)
if (b.guestPhone) set('phone', b.guestPhone)
if (b.guestEmail) set('guestEmail', b.guestEmail)
setShowGuestSugg(false); setGuestDbResults([])
}}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className={cn('w-2 h-2 rounded-full shrink-0', b.status === 'checked_in' ? 'bg-emerald-500' : 'bg-sky-400')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{b.guestName}</p>
<p className="text-[11px] text-slate-400 truncate">{b.guestPhone || (b.status === 'checked_in' ? '🏠 Проживает' : '📅 Бронь')}</p>
</div>
</button>
))}
{guestDbResults
.filter(g => !guestFiltered.some(b => b.guestName.startsWith(g.lastName)))
.map(g => {
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
return (
<button key={g.id} type="button"
onMouseDown={() => {
setGuestFullName(fullName)
set('lastName', g.lastName); set('firstName', g.firstName); set('middleName', g.middleName ?? '')
if (g.phone) set('phone', g.phone)
if (g.email) set('guestEmail', g.email)
setShowGuestSugg(false); setGuestDbResults([])
}}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{fullName}</p>
<p className="text-[11px] text-slate-400 truncate">{g.phone ?? g.email ?? 'нет контактов'}</p>
</div>
</button>
)
})}
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-2">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Телефон</label>
<input
type="tel" className="input" placeholder="+7 (999) 000-00-00"
value={form.phone}
onChange={e => set('phone', e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Email</label>
<input
type="email" className="input" placeholder="guest@example.com"
value={form.guestEmail}
onChange={e => set('guestEmail', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 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-xs text-slate-500 dark:text-slate-400 mb-1">Детей</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-xs text-slate-500 dark:text-slate-400 mb-1">Статус</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>
</div>
{/* Доп. места */}
<div className="flex items-center justify-between px-3 py-2.5 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-1.5">
{extraBeds > 0 && (
<span className="text-[10px] font-medium text-amber-600 dark:text-amber-400">
+{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-4 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>
{/* Статус гостя (теги) */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Статус гостя
</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 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>
{/* 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} />}
Отправить
</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>
)}
</div>
</div>
{/* ── BOTTOM: Примечания + Оплата (полная ширина) ── */}
<div className="border-t border-slate-200 dark:border-slate-700 pt-4 space-y-3">
{/* Примечания */}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Примечания</label>
<textarea
className="input resize-none"
rows={2}
placeholder="Дополнительные пожелания..."
value={form.notes}
onChange={e => set('notes', e.target.value)}
/>
</div>
{/* Оплата — горизонтальная полоса */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/40 px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
{/* Способ оплаты */}
<div className="shrink-0">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Способ оплаты</p>
<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>
{/* Скидка */}
<div className="shrink-0 min-w-[160px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1"><Tag size={11} /> Скидка</p>
<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>
</div>
{/* Доп. услуги */}
<div className="shrink-0 min-w-[180px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Доп. услуги</p>
<div className="flex gap-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 shrink-0">
<Plus size={14} />
</button>
</div>
{addedServices.length > 0 && (
<div className="mt-1.5 space-y-1">
{addedServices.map(s => (
<div key={s.id} className="flex items-center justify-between text-xs px-2 py-0.5 rounded bg-white dark:bg-slate-700">
<span className="text-slate-600 dark:text-slate-300">{s.label} × {s.qty}</span>
<div className="flex items-center gap-1.5">
<span className="font-medium">{(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={10} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Оплачено */}
<div className="shrink-0">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Оплачено ()</p>
<input
type="number" min={0} max={total}
className="input w-32"
value={paidAmount}
onChange={e => setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
/>
</div>
{/* Итого */}
{room && nightCount > 0 && (
<div className="ml-auto text-right shrink-0">
{room && (
<p className="text-xs text-slate-400 mb-0.5">
{isHourly && room.allowHourly
? `${hourlyHours} ч × ${(room.hourlyRate ?? 0).toLocaleString('ru-RU')}`
: `${nightCount} ${nightCount === 1 ? 'ночь' : nightCount < 5 ? 'ночи' : 'ночей'} · ${roomNightlyTotal.toLocaleString('ru-RU')}`
}
{discountAmount > 0 && ` ${discountAmount.toLocaleString('ru-RU')}`}
</p>
)}
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{total.toLocaleString('ru-RU')}
</p>
{paidAmount > 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400">
Оплачено {paidAmount.toLocaleString('ru-RU')}
</p>
)}
{debt > 0 && (
<p className="text-xs text-red-500 flex items-center gap-1 justify-end">
<AlertCircle size={11} />
{isFutureBooking ? 'Задолженность' : 'Долг'} {debt.toLocaleString('ru-RU')}
</p>
)}
</div>
)}
</div>
</div>
</div>
{/* end room form */}
</>)}
</div>
</Modal>
{showFloorMap && (
<FloorMapModal
rooms={rooms}
selectedRoomId={form.roomId}
onSelectRoom={(id) => { set('roomId', id); setShowFloorMap(false) }}
onClose={() => setShowFloorMap(false)}
/>
)}
</>
)
}