- 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>
1353 lines
68 KiB
TypeScript
1353 lines
68 KiB
TypeScript
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)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|