Files
hotelsync/src/components/rooms/RoomModal.tsx
HotelSync 87455c2710 Add centralized amenities directory and hourly booking example
- AmenitiesContext: shared amenities list with add/edit/delete, available app-wide
- RoomsPage: collapsible Справочник удобств section (add/rename/delete)
- RoomModal + RoomCategoriesPage: use amenities from context, no hardcoded lists
- RoomCategoriesPage: removed custom amenity input (use справочник instead)
- mockData: room 103 now has allowHourly=true, added sample hourly booking (today, 10-13ч)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:40:01 +03:00

403 lines
18 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, useRef } from 'react'
import { Modal } from '../ui/Modal'
import { cn } from '../../lib/utils'
import type { Room, RoomStatus, HousekeepingStatus, BedType } from '../../types'
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight } from 'lucide-react'
import { useAmenities } from '../../contexts/AmenitiesContext'
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: 'Проверка' },
]
// ── Tab type ───────────────────────────────────────────────────────────────────
type ModalTab = 'main' | 'description' | 'photos'
interface RoomModalProps {
open: boolean
room?: Room
categories?: { id: string; name: string }[]
onClose: () => void
onSave: (room: Room) => void
}
export function RoomModal({ open, room, categories = [], onClose, onSave }: RoomModalProps) {
const isEdit = !!room
const [tab, setTab] = useState<ModalTab>('main')
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,
categoryId: room?.categoryId ?? '',
description: room?.description ?? '',
})
const { amenities: allAmenities } = useAmenities()
const [amenities, setAmenities] = useState<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
const [photos, setPhotos] = useState<string[]>(room?.photos ?? [])
const [photoIdx, setPhotoIdx] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
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 handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
files.forEach(file => {
const reader = new FileReader()
reader.onload = ev => {
const url = ev.target?.result as string
setPhotos(prev => {
const next = [...prev, url]
setPhotoIdx(next.length - 1)
return next
})
}
reader.readAsDataURL(file)
})
e.target.value = ''
}
const removePhoto = (idx: number) => {
setPhotos(prev => prev.filter((_, i) => i !== idx))
setPhotoIdx(p => Math.max(0, p - 1))
}
const handleSave = () => {
if (!form.number) return
onSave({
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,
categoryId: form.categoryId || undefined,
description: form.description || undefined,
photos: photos.length > 0 ? photos : undefined,
})
}
const TABS: { key: ModalTab; label: string }[] = [
{ key: 'main', label: 'Основное' },
{ key: 'description', label: 'Описание' },
{ key: 'photos', label: `Фото${photos.length > 0 ? ` (${photos.length})` : ''}` },
]
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>
</>
}
>
{/* Tab bar */}
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl mb-4">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
'flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors',
tab === t.key
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900',
)}
>
{t.label}
</button>
))}
</div>
{/* ── Main tab ── */}
{tab === 'main' && (
<div className="space-y-4">
<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>
<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>
{categories.length > 0 && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Категория</label>
<select className="input" value={form.categoryId} onChange={e => set('categoryId', e.target.value)}>
<option value=""> Без категории </option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</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={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>
<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>
<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>
) : (
<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">
{allAmenities.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>
)}
{/* ── Description tab ── */}
{tab === 'description' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Описание номера
</label>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">
Будет отображаться в виджете онлайн-бронирования для гостей.
</p>
<textarea
className="input resize-none w-full"
rows={8}
placeholder="Опишите номер: интерьер, вид из окна, особенности, что включено..."
value={form.description}
onChange={e => set('description', e.target.value)}
/>
<p className="text-xs text-slate-400 mt-1 text-right">{form.description.length} символов</p>
</div>
{form.description && (
<div className="rounded-xl border border-slate-200 dark:border-slate-600 p-4">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">Предпросмотр</p>
<p className="text-sm text-slate-700 dark:text-slate-300 leading-relaxed whitespace-pre-wrap">{form.description}</p>
</div>
)}
</div>
)}
{/* ── Photos tab ── */}
{tab === 'photos' && (
<div className="space-y-4">
{/* Main photo viewer */}
{photos.length > 0 ? (
<div className="space-y-3">
<div className="relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700" style={{ height: 240 }}>
<img
src={photos[photoIdx]}
alt={`Фото ${photoIdx + 1}`}
className="w-full h-full object-cover"
/>
{photos.length > 1 && (
<>
<button
onClick={() => setPhotoIdx(p => (p - 1 + photos.length) % photos.length)}
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70"
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => setPhotoIdx(p => (p + 1) % photos.length)}
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70"
>
<ChevronRight size={16} />
</button>
</>
)}
<button
onClick={() => removePhoto(photoIdx)}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-600 text-white flex items-center justify-center hover:bg-red-700"
>
<XIcon size={13} />
</button>
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-0.5 rounded-full">
{photoIdx + 1} / {photos.length}
</div>
</div>
{/* Thumbnails */}
<div className="flex gap-2 overflow-x-auto pb-1">
{photos.map((p, i) => (
<button
key={i}
onClick={() => setPhotoIdx(i)}
className={cn(
'w-16 h-16 rounded-lg overflow-hidden border-2 shrink-0 transition-colors',
i === photoIdx ? 'border-brand-500' : 'border-transparent',
)}
>
<img src={p} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-48 rounded-xl border-2 border-dashed border-slate-300 dark:border-slate-600 text-slate-400">
<ImagePlus size={32} className="mb-2 opacity-40" />
<p className="text-sm">Фотографий пока нет</p>
</div>
)}
{/* Upload button */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handlePhotoUpload}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="btn-secondary w-full justify-center"
>
<ImagePlus size={15} />
{photos.length > 0 ? 'Добавить ещё фото' : 'Загрузить фотографии'}
</button>
<p className="text-xs text-slate-400 text-center">
Поддерживаются JPG, PNG, WEBP. Первое фото обложка в виджете.
</p>
</div>
)}
</Modal>
)
}