Files
hotelsync/src/pages/RoomCategoriesPage.tsx
HotelSync 588ecb0f2a feat: hourly pricing + category base prices in booking modal + availability grid
- Add hourly_price column to rate_overrides, base_price/allow_hourly/hourly_base_price to room_categories (migration 033)
- Update categories and rate-overrides backend routes to handle new fields
- AvailabilityPage: hourly/nightly mode toggle, period prices auto-applied to grid with 'П' indicator, confirmation dialog when overriding period prices
- BookingModal: nightly/hourly toggle at top, pricing hierarchy (override → category base → room rate)
- RoomCategoriesPage: base_price/allow_hourly/hourly_base_price fields in category form; inline rooms panel
- CalendarPage + BookingCalendar: pass hourlyPriceOverrides and categoryBasePrices down to BookingModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 00:05:14 +03:00

859 lines
40 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, useEffect } from 'react'
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, ChevronDown, ChevronUp, Check, Loader2, DoorOpen } from 'lucide-react'
import { cn } from '../lib/utils'
import type { RoomCategory, Room } from '../types'
import { useAmenities } from '../contexts/AmenitiesContext'
import { useBedTypes } from '../contexts/BedTypesContext'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { CategoryApi } from '../lib/api'
// ── Mock data ─────────────────────────────────────────────────────────────────
const CATEGORY_COLORS = [
'#4F46E5', '#059669', '#2563EB', '#7C3AED',
'#DC2626', '#D97706', '#DB2777', '#475569',
]
function fromApi(c: CategoryApi): RoomCategory {
return {
id: c.id,
hotelId: c.hotel_id,
name: c.name,
description: c.description,
color: c.color,
amenities: c.amenities,
photos: c.photos,
basePrice: c.base_price,
allowHourly: c.allow_hourly,
hourlyBasePrice: c.hourly_base_price,
}
}
// ── Category Form Modal ────────────────────────────────────────────────────────
interface CategoryFormProps {
category?: RoomCategory
onClose: () => void
onSave: (cat: RoomCategory) => Promise<void>
}
function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
const isEdit = !!category
const { amenities: allAmenities } = useAmenities()
const [tab, setTab] = useState<'main' | 'photos'>('main')
const [name, setName] = useState(category?.name ?? '')
const [description, setDescription] = useState(category?.description ?? '')
const [color, setColor] = useState(category?.color ?? '#4F46E5')
const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? [])
const [photos, setPhotos] = useState<string[]>(category?.photos ?? [])
const [basePrice, setBasePrice] = useState(category?.basePrice ?? 0)
const [allowHourly, setAllowHourly] = useState(category?.allowHourly ?? false)
const [hourlyBasePrice, setHourlyBasePrice] = useState(category?.hourlyBasePrice ?? 0)
const [photoIdx, setPhotoIdx] = useState(0)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const [dragging, setDragging] = useState(false)
const [dragSrcIdx, setDragSrcIdx] = useState<number | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const toggleAmenity = (a: string) =>
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
const [uploading, setUploading] = useState(false)
const uploadFiles = async (files: FileList | null) => {
const list = Array.from(files ?? []).filter(f => f.type.startsWith('image/'))
if (!list.length) return
setUploading(true)
try {
const urls = await Promise.all(list.map(f => api.upload.photo(f, 'categories')))
setPhotos(prev => {
const next = [...prev, ...urls]
setPhotoIdx(next.length - 1)
return next
})
} catch {
setSaveError('Ошибка загрузки фото')
} finally {
setUploading(false)
}
}
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
uploadFiles(e.target.files)
e.target.value = ''
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
// Only handle external file drops (not internal thumbnail DnD)
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
// Only show drop highlight for external files, not internal thumbnail reorder
if (e.dataTransfer.types.includes('Files')) {
setDragging(true)
}
}
const removePhoto = (i: number) => {
const url = photos[i]
setPhotos(prev => prev.filter((_, idx) => idx !== i))
setPhotoIdx(p => Math.max(0, p - 1))
// Delete from server (best-effort, don't block UI)
if (url.startsWith('https://cdn.hotelsync.ru/')) {
api.upload.deletePhoto(url).catch(console.error)
}
}
const handleSave = async () => {
if (!name.trim() || saving) return
setSaving(true)
setSaveError('')
try {
await onSave({
id: category?.id ?? `cat-${Date.now()}`,
hotelId: category?.hotelId ?? '',
name: name.trim(),
description,
color,
amenities,
photos,
basePrice,
allowHourly,
hourlyBasePrice,
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения')
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-2xl bg-white dark:bg-slate-800 rounded-2xl shadow-2xl flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{isEdit ? `Редактировать: ${category.name}` : 'Новая категория'}
</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500">
<XIcon size={18} />
</button>
</div>
{/* Tab bar */}
<div className="px-6 pt-3 flex gap-1 p-1 shrink-0">
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-full">
{(['main', 'photos'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors',
tab === t
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-600 dark:text-slate-400',
)}
>
{t === 'main' ? 'Основное' : `Фото${photos.length > 0 ? ` (${photos.length})` : ''}`}
</button>
))}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{tab === 'main' && (
<>
<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={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Цвет категории</label>
<div className="flex items-center gap-2 flex-wrap">
{CATEGORY_COLORS.map(c => (
<button
key={c}
onClick={() => setColor(c)}
className={cn(
'w-8 h-8 rounded-full border-2 transition-transform',
color === c ? 'border-slate-900 dark:border-slate-100 scale-110' : 'border-transparent',
)}
style={{ background: c }}
/>
))}
<input type="color" value={color} onChange={e => setColor(e.target.value)} className="w-8 h-8 rounded-full cursor-pointer" />
</div>
</div>
{/* Pricing */}
<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"
placeholder="0 — брать из номеров"
value={basePrice || ''}
onChange={e => setBasePrice(parseInt(e.target.value) || 0)}
/>
<p className="text-xs text-slate-400 mt-1">0 = цена из номеров категории</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Разрешить почасовую бронь</label>
<button
type="button"
onClick={() => setAllowHourly(v => !v)}
className={cn(
'relative w-11 h-6 rounded-full transition-colors mt-1',
allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
)}
>
<span className={cn(
'absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform',
allowHourly && 'translate-x-5',
)} />
</button>
</div>
</div>
{allowHourly && (
<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={hourlyBasePrice || ''}
onChange={e => setHourlyBasePrice(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>
<p className="text-xs text-slate-500 mb-1">Отображается в виджете бронирования как описание категории</p>
<textarea
className="input resize-none w-full"
rows={4}
placeholder="Опишите категорию для гостей..."
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Удобства в номере</label>
<span className="text-xs text-slate-400">Справочник удобств ниже на этой странице</span>
</div>
<div className="flex flex-wrap gap-2">
{allAmenities.map(a => {
const sel = 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',
sel
? '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>
{amenities.length > 0 && (
<p className="text-xs text-slate-400 mt-1.5">Выбрано: {amenities.length}</p>
)}
</div>
</>
)}
{tab === 'photos' && (
<>
{photos.length > 0 ? (
<div className="space-y-3">
<div
className={cn(
'relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700 transition-colors',
dragging && 'ring-2 ring-brand-500 ring-offset-2',
)}
style={{ height: 220 }}
onDragOver={handleDragOver}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<img src={photos[photoIdx]} alt="" draggable={false} className="w-full h-full object-cover pointer-events-none" />
{dragging && (
<div className="absolute inset-0 bg-brand-600/40 flex items-center justify-center">
<p className="text-white font-semibold text-sm">Отпустите для добавления</p>
</div>
)}
<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"
>
<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>
<div className="flex gap-2 overflow-x-auto pb-1">
{photos.map((p, i) => (
<button
key={i}
draggable
onDragStart={() => setDragSrcIdx(i)}
onDragOver={e => {
e.preventDefault()
if (dragSrcIdx === null || dragSrcIdx === i) return
setPhotos(prev => {
const next = [...prev]
const [moved] = next.splice(dragSrcIdx, 1)
next.splice(i, 0, moved)
return next
})
setPhotoIdx(i)
setDragSrcIdx(i)
}}
onDragEnd={() => setDragSrcIdx(null)}
onClick={() => setPhotoIdx(i)}
className={cn(
'w-14 h-14 rounded-lg overflow-hidden border-2 shrink-0 transition-all cursor-grab active:cursor-grabbing',
i === photoIdx ? 'border-brand-500' : 'border-transparent',
dragSrcIdx === i && 'opacity-50 scale-95',
)}
>
<img src={p} alt="" className="w-full h-full object-cover" />
</button>
))}
<button
onClick={() => fileRef.current?.click()}
className="w-14 h-14 rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-600 flex items-center justify-center text-slate-400 hover:border-brand-400 hover:text-brand-500 shrink-0 transition-colors"
>
<Plus size={18} />
</button>
</div>
</div>
) : (
<div
className={cn(
'flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed transition-colors cursor-pointer',
dragging
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-600'
: 'border-slate-300 dark:border-slate-600 text-slate-400 hover:border-slate-400',
)}
onClick={() => fileRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<ImagePlus size={28} className="mb-2 opacity-60" />
<p className="text-sm font-medium">{dragging ? 'Отпустите файлы' : 'Перетащите фото или нажмите'}</p>
<p className="text-xs mt-0.5 opacity-60">JPG, PNG, WEBP</p>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
{uploading && (
<div className="flex items-center justify-center gap-2 text-sm text-slate-500 py-1">
<Loader2 size={14} className="animate-spin" />
Загрузка...
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700">
{saveError && (
<p className="text-xs text-red-500 mb-3">{saveError}</p>
)}
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary" disabled={saving}>Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!name.trim() || saving}>
{saving ? <Loader2 size={14} className="animate-spin" /> : null}
{isEdit ? 'Сохранить' : 'Создать категорию'}
</button>
</div>
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function RoomCategoriesPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [categories, setCategories] = useState<RoomCategory[]>([])
const [loading, setLoading] = useState(true)
const [rooms, setRooms] = useState<Room[]>([])
const [catRoomsId, setCatRoomsId] = useState<string | null>(null)
const [editingCat, setEditingCat] = useState<RoomCategory | undefined>(undefined)
const [formOpen, setFormOpen] = useState(false)
// Amenities directory state
const { amenities, addAmenity, removeAmenity, renameAmenity } = useAmenities()
const [showAmenities, setShowAmenities] = useState(false)
const [newAmenity, setNewAmenity] = useState('')
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
const [editAmenityValue, setEditAmenityValue] = useState('')
// Bed types directory state
const { bedTypes, addBedType, removeBedType, updateBedType } = useBedTypes()
const [showBedTypes, setShowBedTypes] = useState(false)
const [newBedLabel, setNewBedLabel] = useState('')
const [newBedIcon, setNewBedIcon] = useState('🛏')
const [newBedCapacity, setNewBedCapacity] = useState(2)
const [editingBed, setEditingBed] = useState<string | null>(null)
const [editBedLabel, setEditBedLabel] = useState('')
const [editBedIcon, setEditBedIcon] = useState('')
const [editBedCapacity, setEditBedCapacity] = useState(2)
useEffect(() => {
if (!slug) return
Promise.all([
api.categories.list(slug),
api.rooms.list(slug).catch(() => []),
]).then(([rows, roomRows]) => {
setCategories(rows.map(fromApi))
setRooms(roomRows)
}).catch(console.error)
.finally(() => setLoading(false))
}, [slug])
const openCreate = () => { setEditingCat(undefined); setFormOpen(true) }
const openEdit = (cat: RoomCategory) => { setEditingCat(cat); setFormOpen(true) }
const handleSave = async (cat: RoomCategory) => {
if (!slug) return
const payload = {
name: cat.name, description: cat.description,
color: cat.color, amenities: cat.amenities, photos: cat.photos,
base_price: cat.basePrice ?? 0,
allow_hourly: cat.allowHourly ?? false,
hourly_base_price: cat.hourlyBasePrice ?? 0,
}
const isNew = !categories.find(c => c.id === cat.id)
const saved = isNew
? await api.categories.create(slug, payload)
: await api.categories.update(slug, cat.id, payload)
const converted = fromApi(saved)
setCategories(prev => isNew ? [...prev, converted] : prev.map(c => c.id === cat.id ? converted : c))
setFormOpen(false)
}
const handleDelete = async (id: string) => {
if (!slug) return
await api.categories.delete(slug, id)
setCategories(prev => prev.filter(c => c.id !== id))
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
)
}
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Категории номеров</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Группировка номеров для виджета бронирования и систематизации предложений
</p>
</div>
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />Добавить категорию
</button>
</div>
{/* Explanation */}
<div className="card p-4 border-indigo-200 dark:border-indigo-700/50 bg-indigo-50/50 dark:bg-indigo-900/10">
<div className="flex items-start gap-3">
<Tag size={16} className="text-indigo-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Как работают категории</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Категории позволяют объединять номера в группы (Стандарт, Делюкс, Люкс).
В виджете бронирования гость выбирает категорию, а не конкретный номер.
Категория содержит описание и фотогалерею, которые видят гости на сайте.
</p>
</div>
</div>
</div>
{/* Categories list */}
<div className="space-y-4">
{categories.map(cat => {
return (
<div key={cat.id} className="card p-5">
<div className="flex items-start gap-4">
{/* Color indicator + photo */}
<div
className="w-16 h-16 rounded-xl shrink-0 flex items-center justify-center overflow-hidden"
style={{ background: cat.photos.length > 0 ? undefined : cat.color + '20', borderColor: cat.color, borderWidth: 2 }}
>
{cat.photos.length > 0
? <img src={cat.photos[0]} alt="" className="w-full h-full object-cover" />
: <Tag size={24} style={{ color: cat.color }} />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">{cat.name}</h3>
</div>
{cat.description && (
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2 line-clamp-2">{cat.description}</p>
)}
{/* Amenities */}
{cat.amenities.length > 0 && (
<div className="flex flex-wrap gap-1">
{cat.amenities.map(a => (
<span key={a} className="text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-1.5 py-0.5 rounded">
{a}
</span>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex items-center gap-1.5">
<button
onClick={() => openEdit(cat)}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 hover:border-brand-400 text-slate-500 hover:text-brand-600 transition-colors"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(cat.id)}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 hover:border-red-400 text-slate-500 hover:text-red-500 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
<button
onClick={() => setCatRoomsId(catRoomsId === cat.id ? null : cat.id)}
className={cn(
'flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors whitespace-nowrap',
catRoomsId === cat.id
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600',
)}
>
<span className="hidden sm:inline">Номера</span>
<ChevronRight size={12} className={cn('transition-transform', catRoomsId === cat.id && 'rotate-90')} />
</button>
</div>
</div>
{/* Rooms list panel */}
{catRoomsId === cat.id && (() => {
const catRooms = rooms.filter(r => r.categoryId === cat.id)
return (
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
{catRooms.length === 0 ? (
<p className="text-sm text-slate-400 flex items-center gap-2">
<DoorOpen size={15} />
Нет номеров в этой категории
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{catRooms.sort((a, b) => a.sortOrder - b.sortOrder).map(r => (
<div key={r.id} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 text-sm">
<DoorOpen size={13} className="text-slate-400 shrink-0" />
<span className="font-medium text-slate-700 dark:text-slate-200">{r.number}</span>
<span className="text-slate-400 text-xs truncate">{r.baseRate.toLocaleString('ru-RU')} </span>
</div>
))}
</div>
)}
</div>
)
})()}
</div>
)
})}
{categories.length === 0 && (
<div className="card p-10 text-center">
<Tag size={36} className="mx-auto mb-3 text-slate-300" />
<p className="font-medium text-slate-600 dark:text-slate-400">Категории не созданы</p>
<p className="text-sm text-slate-400 mt-1">Создайте первую категорию для группировки номеров</p>
<button className="btn-primary mt-4" onClick={openCreate}>
<Plus size={14} />Создать категорию
</button>
</div>
)}
</div>
{/* Справочник удобств */}
<div className="card overflow-hidden">
<button
onClick={() => setShowAmenities(v => !v)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Справочник удобств</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{amenities.length} удобств · используется в номерах и категориях
</p>
</div>
{showAmenities
? <ChevronUp size={16} className="text-slate-400" />
: <ChevronDown size={16} className="text-slate-400" />}
</button>
{showAmenities && (
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
<div className="flex gap-2">
<input
type="text"
className="input flex-1 text-sm"
placeholder="Добавить удобство (напр. Фен, Сейф, Балкон...)"
value={newAmenity}
onChange={e => setNewAmenity(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }
}}
/>
<button
onClick={() => { if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }}
disabled={!newAmenity.trim() || amenities.includes(newAmenity.trim())}
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={15} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{amenities.map(a => (
<div
key={a}
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 group"
>
{editingAmenity === a ? (
<>
<input
autoFocus
className="input text-xs flex-1 py-0.5 h-6"
value={editAmenityValue}
onChange={e => setEditAmenityValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }
if (e.key === 'Escape') setEditingAmenity(null)
}}
/>
<button onClick={() => { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }}
className="text-emerald-500 hover:text-emerald-600 shrink-0"><Check size={13} /></button>
<button onClick={() => setEditingAmenity(null)}
className="text-slate-400 hover:text-slate-600 shrink-0"><XIcon size={13} /></button>
</>
) : (
<>
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{a}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => { setEditingAmenity(a); setEditAmenityValue(a) }}
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
>
<Pencil size={11} />
</button>
<button
onClick={() => removeAmenity(a)}
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Справочник спальных мест */}
<div className="card overflow-hidden">
<button
onClick={() => setShowBedTypes(v => !v)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Справочник спальных мест</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{bedTypes.length} типов · используется при настройке номеров
</p>
</div>
{showBedTypes
? <ChevronUp size={16} className="text-slate-400" />
: <ChevronDown size={16} className="text-slate-400" />}
</button>
{showBedTypes && (
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
{/* Add new */}
<div className="flex gap-2">
<select
className="input w-14 text-lg px-2 text-center"
value={newBedIcon}
onChange={e => setNewBedIcon(e.target.value)}
>
{['🛏', '🛋', '🪑', '🛌', '🚪'].map(ic => (
<option key={ic} value={ic}>{ic}</option>
))}
</select>
<input
type="text"
className="input flex-1 text-sm"
placeholder="Название (напр. Водяная кровать...)"
value={newBedLabel}
onChange={e => setNewBedLabel(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
const label = newBedLabel.trim()
if (!label) return
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '') + '_' + Date.now().toString(36)
addBedType({ value, label, icon: newBedIcon, capacity: newBedCapacity })
setNewBedLabel(''); setNewBedIcon('🛏'); setNewBedCapacity(2)
}
}}
/>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">мест:</span>
<input
type="number" min={1} max={10}
className="input w-14 text-sm text-center"
value={newBedCapacity}
onChange={e => setNewBedCapacity(parseInt(e.target.value) || 1)}
/>
</div>
<button
onClick={() => {
const label = newBedLabel.trim()
if (!label) return
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '') + '_' + Date.now().toString(36)
addBedType({ value, label, icon: newBedIcon, capacity: newBedCapacity })
setNewBedLabel(''); setNewBedIcon('🛏'); setNewBedCapacity(2)
}}
disabled={!newBedLabel.trim()}
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={15} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{bedTypes.map(b => (
<div
key={b.value}
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 group"
>
{editingBed === b.value ? (
<>
<select
className="text-base w-8 shrink-0 bg-transparent"
value={editBedIcon}
onChange={e => setEditBedIcon(e.target.value)}
>
{['🛏', '🛋', '🪑', '🛌', '🚪'].map(ic => (
<option key={ic} value={ic}>{ic}</option>
))}
</select>
<input
autoFocus
className="input text-xs flex-1 py-0.5 h-6"
value={editBedLabel}
onChange={e => setEditBedLabel(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
updateBedType(b.value, { label: editBedLabel, icon: editBedIcon, capacity: editBedCapacity })
setEditingBed(null)
}
if (e.key === 'Escape') setEditingBed(null)
}}
/>
<input
type="number" min={1} max={10}
className="input text-xs w-10 py-0.5 h-6 text-center"
value={editBedCapacity}
onChange={e => setEditBedCapacity(parseInt(e.target.value) || 1)}
title="Вместимость"
/>
<button
onClick={() => { updateBedType(b.value, { label: editBedLabel, icon: editBedIcon, capacity: editBedCapacity }); setEditingBed(null) }}
className="text-emerald-500 hover:text-emerald-600 shrink-0"
>
<Check size={13} />
</button>
<button onClick={() => setEditingBed(null)} className="text-slate-400 hover:text-slate-600 shrink-0">
<XIcon size={13} />
</button>
</>
) : (
<>
<span className="text-base shrink-0">{b.icon}</span>
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{b.label}</span>
<span className="text-[10px] text-slate-400 shrink-0">{b.capacity} чел.</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => { setEditingBed(b.value); setEditBedLabel(b.label); setEditBedIcon(b.icon); setEditBedCapacity(b.capacity) }}
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
>
<Pencil size={11} />
</button>
<button
onClick={() => removeBedType(b.value)}
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
))}
</div>
</div>
)}
</div>
{formOpen && (
<CategoryForm
category={editingCat}
onClose={() => setFormOpen(false)}
onSave={handleSave}
/>
)}
</div>
)
}