673 lines
34 KiB
TypeScript
673 lines
34 KiB
TypeScript
import { useState, useRef } from 'react'
|
||
import { Modal } from '../ui/Modal'
|
||
import { cn } from '../../lib/utils'
|
||
import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, BedItemType, ExtraPlace, ChildPolicy } from '../../types'
|
||
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby, Trash2 } 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: 'Проверка' },
|
||
]
|
||
|
||
const BED_ITEM_TYPES: { value: BedItemType; label: string; icon: string }[] = [
|
||
{ value: 'single', label: 'Кровать 1-сп.', icon: '🛏' },
|
||
{ value: 'double', label: 'Кровать 2-сп.', icon: '🛏' },
|
||
{ value: 'queen', label: 'Queen-кровать', icon: '🛏' },
|
||
{ value: 'king', label: 'King-кровать', icon: '🛏' },
|
||
{ value: 'twin', label: 'Две кровати', icon: '🛏' },
|
||
{ value: 'sofa', label: 'Диван', icon: '🛋' },
|
||
{ value: 'bunk', label: 'Двухъярусная', icon: '🛏' },
|
||
{ value: 'cot', label: 'Раскладушка', icon: '🪑' },
|
||
]
|
||
|
||
// ── Tab type ───────────────────────────────────────────────────────────────────
|
||
type ModalTab = 'main' | 'places' | 'description' | 'photos'
|
||
|
||
interface RoomModalProps {
|
||
open: boolean
|
||
room?: Room
|
||
categories?: { id: string; name: string }[]
|
||
onClose: () => void
|
||
onSave: (room: Room) => void
|
||
onDelete?: () => void
|
||
error?: string | null
|
||
}
|
||
|
||
export function RoomModal({ open, room, categories = [], onClose, onSave, onDelete, error }: 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 ?? (categories[0]?.name ?? 'Стандарт'),
|
||
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 ?? (categories.find(c => c.name === room?.type)?.id ?? categories[0]?.id ?? ''),
|
||
description: room?.description ?? '',
|
||
earlyCheckinFee: room?.earlyCheckinFee ?? '',
|
||
lateCheckoutFee: room?.lateCheckoutFee ?? '',
|
||
})
|
||
|
||
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)
|
||
|
||
// ── Beds constructor ──────────────────────────────────────────────────────
|
||
const [beds, setBeds] = useState<BedItem[]>(
|
||
room?.beds ?? [{ type: 'double', count: 1 }]
|
||
)
|
||
const [extraPlace, setExtraPlace] = useState<ExtraPlace>(
|
||
room?.extraPlace ?? { enabled: false, count: 1, price: 1500 }
|
||
)
|
||
const [childPolicy, setChildPolicy] = useState<ChildPolicy>(
|
||
room?.childPolicy ?? { enabled: false, freeUnderAge: 12, chargeFromAge: 12 }
|
||
)
|
||
|
||
const addBed = () => setBeds(prev => [...prev, { type: 'single', count: 1 }])
|
||
const removeBed = (i: number) => setBeds(prev => prev.filter((_, idx) => idx !== i))
|
||
const updateBed = (i: number, patch: Partial<BedItem>) =>
|
||
setBeds(prev => prev.map((b, idx) => idx === i ? { ...b, ...patch } : b))
|
||
|
||
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,
|
||
beds: beds.length > 0 ? beds : undefined,
|
||
extraPlace: extraPlace.enabled ? extraPlace : undefined,
|
||
childPolicy: childPolicy.enabled ? childPolicy : undefined,
|
||
earlyCheckinFee: form.earlyCheckinFee !== '' ? Number(form.earlyCheckinFee) : undefined,
|
||
lateCheckoutFee: form.lateCheckoutFee !== '' ? Number(form.lateCheckoutFee) : undefined,
|
||
})
|
||
}
|
||
|
||
const TABS: { key: ModalTab; label: string }[] = [
|
||
{ key: 'main', label: 'Основное' },
|
||
{ key: 'places', 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={
|
||
<>
|
||
{isEdit && onDelete && (
|
||
<button
|
||
onClick={onDelete}
|
||
className="btn-ghost text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 mr-auto"
|
||
>
|
||
<Trash2 size={14} />
|
||
Удалить номер
|
||
</button>
|
||
)}
|
||
{error && (
|
||
<p className="text-sm text-red-600 dark:text-red-400 flex-1 text-left">{error}</p>
|
||
)}
|
||
<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>
|
||
{categories.length > 0 ? (
|
||
<select
|
||
className="input"
|
||
value={form.categoryId}
|
||
onChange={e => {
|
||
const cat = categories.find(c => c.id === e.target.value)
|
||
set('categoryId', e.target.value)
|
||
if (cat) set('type', cat.name)
|
||
}}
|
||
>
|
||
<option value="">— Выберите категорию —</option>
|
||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
) : (
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{/* Early check-in / late checkout fees */}
|
||
<div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-700">
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Тарифы за ранний/поздний заезд</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Применяются если включено в настройках отеля</p>
|
||
</div>
|
||
<div className="p-4 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"
|
||
placeholder="0 = бесплатно"
|
||
value={form.earlyCheckinFee}
|
||
onChange={e => set('earlyCheckinFee', 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="number" min={0} className="input"
|
||
placeholder="0 = бесплатно"
|
||
value={form.lateCheckoutFee}
|
||
onChange={e => set('lateCheckoutFee', e.target.value)}
|
||
/>
|
||
</div>
|
||
</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>
|
||
)}
|
||
|
||
{/* ── Places tab ── */}
|
||
{tab === 'places' && (
|
||
<div className="space-y-5">
|
||
|
||
{/* Beds constructor */}
|
||
<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">
|
||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Спальные места</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Кровати, диваны и другая мебель для сна</p>
|
||
</div>
|
||
<div className="p-4 space-y-3">
|
||
{beds.map((bed, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<span className="text-lg w-7 text-center shrink-0">
|
||
{BED_ITEM_TYPES.find(b => b.value === bed.type)?.icon ?? '🛏'}
|
||
</span>
|
||
<select
|
||
className="input flex-1"
|
||
value={bed.type}
|
||
onChange={e => updateBed(i, { type: e.target.value as BedItemType })}
|
||
>
|
||
{BED_ITEM_TYPES.map(b => (
|
||
<option key={b.value} value={b.value}>{b.label}</option>
|
||
))}
|
||
</select>
|
||
{/* Count stepper */}
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateBed(i, { count: Math.max(1, bed.count - 1) })}
|
||
className="w-7 h-7 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||
>
|
||
<Minus size={12} />
|
||
</button>
|
||
<span className="w-6 text-center text-sm font-medium text-slate-700 dark:text-slate-300">{bed.count}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateBed(i, { count: Math.min(6, bed.count + 1) })}
|
||
className="w-7 h-7 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||
>
|
||
<Plus size={12} />
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeBed(i)}
|
||
disabled={beds.length === 1}
|
||
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||
>
|
||
<XIcon size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={addBed}
|
||
className="btn-secondary w-full justify-center text-xs py-1.5"
|
||
>
|
||
<Plus size={13} />
|
||
Добавить спальное место
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Extra places */}
|
||
<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">
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Дополнительные места</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Раскладные кровати, кушетки за доп. плату</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setExtraPlace(p => ({ ...p, enabled: !p.enabled }))}
|
||
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', extraPlace.enabled ? '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', extraPlace.enabled ? 'left-[22px]' : 'left-0.5')} />
|
||
</button>
|
||
</div>
|
||
{extraPlace.enabled && (
|
||
<div className="p-4 grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Кол-во мест</label>
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" onClick={() => setExtraPlace(p => ({ ...p, count: Math.max(1, p.count - 1) }))}
|
||
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||
<Minus size={13} />
|
||
</button>
|
||
<span className="w-8 text-center font-semibold text-slate-800 dark:text-slate-200">{extraPlace.count}</span>
|
||
<button type="button" onClick={() => setExtraPlace(p => ({ ...p, count: Math.min(5, p.count + 1) }))}
|
||
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||
<Plus size={13} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Цена ₽/ночь</label>
|
||
<input
|
||
type="number" min={0} className="input"
|
||
value={extraPlace.price}
|
||
onChange={e => setExtraPlace(p => ({ ...p, price: parseInt(e.target.value) || 0 }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Child policy */}
|
||
<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">
|
||
<div className="flex items-center gap-2">
|
||
<Baby size={16} className="text-slate-500" />
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Политика для детей</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Возраст бесплатного проживания</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setChildPolicy(p => ({ ...p, enabled: !p.enabled }))}
|
||
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', childPolicy.enabled ? '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', childPolicy.enabled ? 'left-[22px]' : 'left-0.5')} />
|
||
</button>
|
||
</div>
|
||
{childPolicy.enabled ? (
|
||
<div className="p-4 space-y-3">
|
||
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||
<span className="text-xl">🆓</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-xs font-medium text-emerald-800 dark:text-emerald-300">Бесплатно до</p>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, freeUnderAge: Math.max(1, p.freeUnderAge - 1) }))}
|
||
className="w-7 h-7 rounded-lg border border-emerald-200 dark:border-emerald-700 flex items-center justify-center text-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/40">
|
||
<Minus size={12} />
|
||
</button>
|
||
<span className="w-16 text-center text-sm font-semibold text-emerald-800 dark:text-emerald-300">
|
||
до {childPolicy.freeUnderAge} лет
|
||
</span>
|
||
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, freeUnderAge: Math.min(17, p.freeUnderAge + 1) }))}
|
||
className="w-7 h-7 rounded-lg border border-emerald-200 dark:border-emerald-700 flex items-center justify-center text-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/40">
|
||
<Plus size={12} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||
<span className="text-xl">💰</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">Доп. место от</p>
|
||
{extraPlace.enabled && (
|
||
<p className="text-[10px] text-amber-600 dark:text-amber-400">{extraPlace.price} ₽/ночь</p>
|
||
)}
|
||
{!extraPlace.enabled && (
|
||
<p className="text-[10px] text-amber-600 dark:text-amber-400">Включите «Доп. места» и укажите цену</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, chargeFromAge: Math.max(1, p.chargeFromAge - 1) }))}
|
||
className="w-7 h-7 rounded-lg border border-amber-200 dark:border-amber-700 flex items-center justify-center text-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
||
<Minus size={12} />
|
||
</button>
|
||
<span className="w-16 text-center text-sm font-semibold text-amber-800 dark:text-amber-300">
|
||
от {childPolicy.chargeFromAge} лет
|
||
</span>
|
||
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, chargeFromAge: Math.min(17, p.chargeFromAge + 1) }))}
|
||
className="w-7 h-7 rounded-lg border border-amber-200 dark:border-amber-700 flex items-center justify-center text-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
||
<Plus size={12} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||
Дети до {childPolicy.freeUnderAge} лет — бесплатно без доп. места.
|
||
Дети от {childPolicy.chargeFromAge} лет — оплачивают дополнительное место.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="px-4 py-3">
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Стандартные условия, все гости оплачиваются</p>
|
||
</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>
|
||
)
|
||
}
|