- BookingModal: 2-column layout (size=2xl/max-w-3xl), no scroll needed; hourly mode toggle for rooms with allowHourly=true - RoomModal: full add/edit form with hourly rate toggle and amenities checkboxes; RoomsPage wired up - Channel manager: Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip added; split into RU/International sections - Migration module: 3-step wizard (source select → file upload → progress/results) - All modules set to active by default (version bump to reset localStorage) - BookingCalendar booking blocks: guest count badge (Xг), unpaid red dot indicator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { useState } from 'react'
|
||
import { Modal } from '../ui/Modal'
|
||
import { cn } from '../../lib/utils'
|
||
import type { Room, RoomStatus, HousekeepingStatus, BedType } from '../../types'
|
||
|
||
const AMENITY_LIST = [
|
||
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
|
||
'Panoramic view', 'Kitchen', 'Washing machine', 'Butler', 'Terrace',
|
||
]
|
||
|
||
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
|
||
|
||
const BED_TYPES: { value: BedType; label: string }[] = [
|
||
{ value: 'single', label: 'Одна кровать' },
|
||
{ value: 'double', label: 'Двуспальная' },
|
||
{ value: 'queen', label: 'Queen' },
|
||
{ value: 'king', label: 'King' },
|
||
{ value: 'twin', label: 'Две кровати' },
|
||
]
|
||
|
||
const ROOM_STATUSES: { value: RoomStatus; label: string }[] = [
|
||
{ value: 'available', label: 'Свободен' },
|
||
{ value: 'occupied', label: 'Занят' },
|
||
{ value: 'maintenance', label: 'Ремонт' },
|
||
{ value: 'blocked', label: 'Закрыт' },
|
||
]
|
||
|
||
const HK_STATUSES: { value: HousekeepingStatus; label: string }[] = [
|
||
{ value: 'clean', label: 'Чистый' },
|
||
{ value: 'dirty', label: 'Грязный' },
|
||
{ value: 'cleaning', label: 'Убирается' },
|
||
{ value: 'inspect', label: 'Проверка' },
|
||
]
|
||
|
||
interface RoomModalProps {
|
||
open: boolean
|
||
room?: Room
|
||
onClose: () => void
|
||
onSave: (room: Room) => void
|
||
}
|
||
|
||
export function RoomModal({ open, room, onClose, onSave }: RoomModalProps) {
|
||
const isEdit = !!room
|
||
|
||
const [form, setForm] = useState({
|
||
number: room?.number ?? '',
|
||
name: room?.name ?? '',
|
||
floor: room?.floor ?? 1,
|
||
type: room?.type ?? 'Стандарт',
|
||
bedType: (room?.bedType ?? 'double') as BedType,
|
||
maxGuests: room?.maxGuests ?? 2,
|
||
baseRate: room?.baseRate ?? 5000,
|
||
status: (room?.status ?? 'available') as RoomStatus,
|
||
housekeepingStatus: (room?.housekeepingStatus ?? 'clean') as HousekeepingStatus,
|
||
sortOrder: room?.sortOrder ?? 99,
|
||
allowHourly: room?.allowHourly ?? false,
|
||
hourlyRate: room?.hourlyRate ?? 1000,
|
||
})
|
||
|
||
const [amenities, setAmenities] = useState<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
|
||
|
||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||
setForm(prev => ({ ...prev, [k]: v }))
|
||
|
||
const toggleAmenity = (a: string) =>
|
||
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
|
||
|
||
const handleSave = () => {
|
||
if (!form.number) return
|
||
const saved: Room = {
|
||
id: room?.id ?? `r-${Date.now()}`,
|
||
hotelId: room?.hotelId ?? 'hotel-1',
|
||
number: form.number,
|
||
name: form.name || undefined,
|
||
floor: form.floor,
|
||
type: form.type,
|
||
bedType: form.bedType,
|
||
maxGuests: form.maxGuests,
|
||
baseRate: form.baseRate,
|
||
status: form.status,
|
||
housekeepingStatus: form.housekeepingStatus,
|
||
amenities,
|
||
sortOrder: form.sortOrder,
|
||
allowHourly: form.allowHourly || undefined,
|
||
hourlyRate: form.allowHourly ? form.hourlyRate : undefined,
|
||
}
|
||
onSave(saved)
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={onClose}
|
||
title={isEdit ? `Редактировать номер ${room.number}` : 'Добавить номер'}
|
||
size="xl"
|
||
footer={
|
||
<>
|
||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||
<button onClick={handleSave} className="btn-primary" disabled={!form.number}>
|
||
{isEdit ? 'Сохранить' : 'Добавить номер'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
{/* Row 1: Number | Name */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Номер *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
placeholder="101"
|
||
value={form.number}
|
||
onChange={e => set('number', 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.name}
|
||
onChange={e => set('name', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Floor | Type */}
|
||
<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="number"
|
||
min={1}
|
||
max={20}
|
||
className="input"
|
||
value={form.floor}
|
||
onChange={e => set('floor', 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>
|
||
<select
|
||
className="input"
|
||
value={form.type}
|
||
onChange={e => set('type', e.target.value)}
|
||
>
|
||
{ROOM_TYPES.map(t => (
|
||
<option key={t} value={t}>{t}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 3: Bed type | Max guests */}
|
||
<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={form.bedType}
|
||
onChange={e => set('bedType', e.target.value as BedType)}
|
||
>
|
||
{BED_TYPES.map(b => (
|
||
<option key={b.value} value={b.value}>{b.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Макс. гостей
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
className="input"
|
||
value={form.maxGuests}
|
||
onChange={e => set('maxGuests', parseInt(e.target.value) || 1)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 4: Base rate | Status */}
|
||
<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="number"
|
||
min={0}
|
||
className="input"
|
||
value={form.baseRate}
|
||
onChange={e => set('baseRate', parseInt(e.target.value) || 0)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Статус номера
|
||
</label>
|
||
<select
|
||
className="input"
|
||
value={form.status}
|
||
onChange={e => set('status', e.target.value as RoomStatus)}
|
||
>
|
||
{ROOM_STATUSES.map(s => (
|
||
<option key={s.value} value={s.value}>{s.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 5: Housekeeping | Sort order */}
|
||
<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={form.housekeepingStatus}
|
||
onChange={e => set('housekeepingStatus', e.target.value as HousekeepingStatus)}
|
||
>
|
||
{HK_STATUSES.map(s => (
|
||
<option key={s.value} value={s.value}>{s.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Порядок сортировки
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
className="input"
|
||
value={form.sortOrder}
|
||
onChange={e => set('sortOrder', parseInt(e.target.value) || 1)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hourly section */}
|
||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Почасовая аренда</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => set('allowHourly', !form.allowHourly)}
|
||
className={cn(
|
||
'relative w-11 h-6 rounded-full transition-colors',
|
||
form.allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
|
||
)}
|
||
>
|
||
<div className={cn(
|
||
'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
|
||
form.allowHourly ? 'left-[22px]' : 'left-0.5',
|
||
)} />
|
||
</button>
|
||
</div>
|
||
{form.allowHourly && (
|
||
<div className="p-4">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
Цена ₽/час
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
className="input w-40"
|
||
value={form.hourlyRate}
|
||
onChange={e => set('hourlyRate', parseInt(e.target.value) || 0)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{!form.allowHourly && (
|
||
<div className="px-4 py-3">
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
Разрешить бронирование номера на несколько часов
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Amenities */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||
Удобства
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{AMENITY_LIST.map(a => {
|
||
const selected = amenities.includes(a)
|
||
return (
|
||
<button
|
||
key={a}
|
||
type="button"
|
||
onClick={() => toggleAmenity(a)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||
selected
|
||
? 'bg-brand-600 text-white border-brand-600'
|
||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
|
||
)}
|
||
>
|
||
{a}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|