- 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>
859 lines
40 KiB
TypeScript
859 lines
40 KiB
TypeScript
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>
|
||
)
|
||
}
|