Files
hotelsync/src/components/rooms/RoomModal.tsx
HotelSync 0e0c11785f Wide BookingModal (2-col), RoomModal, RU channels, migration module, all modules active
- 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>
2026-03-11 19:16:55 +03:00

327 lines
12 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 } 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>
)
}