BookingModal: redesign — dates first, category cards, specific room toggle

This commit is contained in:
2026-03-20 23:45:11 +03:00
parent babd6e950e
commit 0464259273

View File

@@ -51,6 +51,17 @@ function availableRooms(rooms: Room[], bookings: Booking[], checkIn: string, che
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 }: BookingModalProps) {
const { statuses } = useModules()
const { user } = useAuth()
@@ -80,8 +91,16 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
}
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: existing?.roomId ?? draft.roomId,
roomId: _initRoomId,
lastName: _nameParts[0] ?? '',
firstName: _nameParts[1] ?? '',
middleName: _nameParts.slice(2).join(' '),
@@ -115,6 +134,34 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
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
@@ -214,136 +261,167 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
{/* ── LEFT COLUMN ── */}
<div className="flex-1 space-y-3 min-w-0">
{/* Room */}
<div className="flex-1 space-y-4 min-w-0">
{/* 1. Даты заезда / выезда */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Номер
<label 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 => set('checkIn', e.target.value)}
/>
</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} />
Поэтажный план
<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"
>
{rooms.map(r => (
{categoryAvailableRooms.map(r => (
<option key={r.id} value={r.id}>
{r.number} {r.type} ({r.baseRate.toLocaleString('ru-RU')} /ночь{r.allowHourly ? `, ${(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч` : ''})
{r.number}{r.floor ? `, ${r.floor} эт.` : ''} {r.baseRate.toLocaleString('ru-RU')} /ночь
{r.allowHourly ? ` · ${(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч` : ''}
</option>
))}
</select>
{/* Conflict warning */}
{!isHourly && form.roomId && form.checkIn && form.checkOut && form.checkIn < form.checkOut &&
isConflict(bookings, form.roomId, form.checkIn, form.checkOut, existing?.id) && (() => {
const free = availableRooms(rooms, bookings, form.checkIn, form.checkOut, existing?.id)
return (
<div className="mt-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700">
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-300 text-xs mb-2">
<AlertCircle size={13} className="shrink-0 mt-0.5" />
<span>Номер занят на эти даты.</span>
</div>
{free.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-amber-700 dark:text-amber-400 self-center">Свободны:</span>
{free.map(r => (
<button
key={r.id}
type="button"
onClick={() => set('roomId', r.id)}
className="text-xs px-2 py-0.5 rounded-md bg-white dark:bg-slate-700 border border-amber-300 dark:border-amber-600 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30 font-medium transition-colors"
>
{r.number} ({r.type})
</button>
))}
</div>
) : (
<p className="text-xs text-amber-700 dark:text-amber-400">Нет свободных номеров на эти даты.</p>
<div 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 className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Фамилия *
</label>
<input
type="text"
className="input"
placeholder="Иванов"
value={form.lastName}
onChange={e => set('lastName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Имя *
</label>
<input
type="text"
className="input"
placeholder="Иван"
value={form.firstName}
onChange={e => set('firstName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Отчество
</label>
<input
type="text"
className="input"
placeholder="Иванович"
value={form.middleName}
onChange={e => set('middleName', e.target.value)}
/>
</div>
</div>
{/* Телефон + Email */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Телефон
</label>
<input
type="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-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Email
</label>
<input
type="email"
className="input"
placeholder="guest@example.com"
value={form.guestEmail}
onChange={e => set('guestEmail', e.target.value)}
/>
</div>
</div>
{/* Hourly / Daily toggle */}
{/* Почасово / посуточно — только если у выбранного номера есть почасовая ставка */}
{showHourlyTab && (
<div className="flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600">
<div className="mt-3 flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600">
<button
type="button"
onClick={() => setIsHourly(false)}
@@ -370,107 +448,79 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
</button>
</div>
)}
</div>
{/* Dates */}
{isHourly && room?.allowHourly ? (
<div className="space-y-3">
{/* 3. ФИО гостя */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Дата *
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Гость
</label>
<input
type="date"
className="input"
value={hourlyDate}
onChange={e => setHourlyDate(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Начало
</label>
<select
className="input"
value={startHour}
onChange={e => setStartHour(parseInt(e.target.value))}
>
{HOURS.map(h => (
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Конец
</label>
<select
className="input"
value={endHour}
onChange={e => setEndHour(parseInt(e.target.value))}
>
{HOURS.filter(h => h > startHour).map(h => (
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
))}
</select>
</div>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Заезд *
</label>
<input
type="date"
className="input"
value={form.checkIn}
onChange={e => set('checkIn', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Выезд *
</label>
<input
type="date"
className="input"
value={form.checkOut}
onChange={e => set('checkOut', e.target.value)}
/>
</div>
</div>
)}
{/* Adults + Children + Status */}
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Взрослых
</label>
<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"
type="text" className="input" placeholder="Иванов"
value={form.lastName}
onChange={e => set('lastName', e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Имя *</label>
<input
type="text" className="input" placeholder="Иван"
value={form.firstName}
onChange={e => set('firstName', e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Отчество</label>
<input
type="text" className="input" placeholder="Иванович"
value={form.middleName}
onChange={e => set('middleName', e.target.value)}
/>
</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>
{/* 4. Взрослые / Дети / Статус */}
<div className="grid grid-cols-3 gap-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-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Детей
</label>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Детей</label>
<input
type="number" min={0} max={4}
className="input"
type="number" min={0} max={4} className="input"
value={form.children}
onChange={e => set('children', parseInt(e.target.value) || 0)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Статус
</label>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Статус</label>
<select
className="input"
value={form.status}
@@ -506,9 +556,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
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} />}
{tvSending ? <Loader2 size={12} className="animate-spin" /> : <Send size={12} />}
Отправить на TV
</button>
{tvSent && (
@@ -516,15 +564,13 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
<CheckCircle2 size={12} /> Отправлено
</span>
)}
{tvError && (
<span className="text-xs text-red-500">{tvError}</span>
)}
{tvError && <span className="text-xs text-red-500">{tvError}</span>}
</div>
</div>
)}
{/* Extra beds */}
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
{/* Доп. места */}
<div className="flex items-center 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>
@@ -541,22 +587,18 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
<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>
><Minus size={11} /></button>
<span className="w-5 text-center text-sm font-semibold text-slate-900 dark:text-slate-100">{extraBeds}</span>
<button type="button" onClick={() => setExtraBeds(e => Math.min(4, e + 1))}
className="w-6 h-6 rounded-md bg-slate-200 dark:bg-slate-600 flex items-center justify-center hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors disabled:opacity-40"
disabled={extraBeds >= 4}
>
<Plus size={11} />
</button>
><Plus size={11} /></button>
</div>
</div>
{/* Guest tags */}
{/* Статус гостя (теги) */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Статус гостя
</label>
<div className="flex flex-wrap gap-1.5">