Files
hotelsync/src/pages/RoomCategoriesPage.tsx
HotelSync 96dfd755ed Add room categories, documents module, guest settings, and booking improvements
- New pages: RoomCategoriesPage (category CRUD with photos, color, amenities) and DocumentsPage (template constructor with variable substitution, print packages)
- Sidebar: added "Категории номеров" link under rooms, Documents module via modulesData
- App.tsx: routes for /room-categories and /documents
- RoomModal: now accepts categories prop for categoryId select
- BookingModal: added additional services dropdown (breakfast, transfer, parking etc.) accumulating into booking total; summary shows services breakdown
- SettingsPage: new "Гости" section with guest tag CRUD (add/edit/remove, color picker, live preview); booking section toggle "Показывать поле Источник бронирования"
- modulesData: added Documents module entry (free tier, sidebar item)
- RoomsPage: passes MOCK_CATEGORIES to RoomModal
- ReviewsPage: rewritten with auto-redirect logic, threshold slider, platform settings tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:17:54 +03:00

406 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 { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight } from 'lucide-react'
import { cn } from '../lib/utils'
import type { RoomCategory } from '../types'
// ── Mock data ─────────────────────────────────────────────────────────────────
const CATEGORY_COLORS = [
'#4F46E5', '#059669', '#2563EB', '#7C3AED',
'#DC2626', '#D97706', '#DB2777', '#475569',
]
const AMENITY_LIST = [
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
'Panoramic view', 'Kitchen', 'Terrace', 'Butler',
]
export const MOCK_CATEGORIES: RoomCategory[] = [
{
id: 'cat1',
hotelId: 'hotel-1',
name: 'Стандарт',
description: 'Уютные номера с базовым набором удобств. Идеально для деловых поездок и короткого отдыха.',
photos: [],
color: '#4F46E5',
amenities: ['Wi-Fi', 'TV', 'AC'],
},
{
id: 'cat2',
hotelId: 'hotel-1',
name: 'Делюкс',
description: 'Просторные номера с улучшенным интерьером, панорамными окнами и расширенным набором услуг.',
photos: [],
color: '#059669',
amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe'],
},
{
id: 'cat3',
hotelId: 'hotel-1',
name: 'Люкс & Пентхаус',
description: 'Роскошные апартаменты на верхних этажах с потрясающими видами, джакузи и персональным дворецким.',
photos: [],
color: '#7C3AED',
amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi', 'Panoramic view', 'Butler', 'Terrace'],
},
]
// Mock room counts per category
const MOCK_ROOM_COUNTS: Record<string, number> = {
cat1: 8,
cat2: 5,
cat3: 2,
}
// ── Category Form Modal ────────────────────────────────────────────────────────
interface CategoryFormProps {
category?: RoomCategory
onClose: () => void
onSave: (cat: RoomCategory) => void
}
function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
const isEdit = !!category
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 [photoIdx, setPhotoIdx] = useState(0)
const fileRef = useRef<HTMLInputElement>(null)
const toggleAmenity = (a: string) =>
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
Array.from(e.target.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 = (i: number) => {
setPhotos(prev => prev.filter((_, idx) => idx !== i))
setPhotoIdx(p => Math.max(0, p - 1))
}
const handleSave = () => {
if (!name.trim()) return
onSave({
id: category?.id ?? `cat-${Date.now()}`,
hotelId: category?.hotelId ?? 'hotel-1',
name: name.trim(),
description,
color,
amenities,
photos,
})
}
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>
<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>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Базовые удобства</label>
<div className="flex flex-wrap gap-2">
{AMENITY_LIST.map(a => {
const 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>
</div>
</>
)}
{tab === 'photos' && (
<>
{photos.length > 0 ? (
<div className="space-y-3">
<div className="relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700" style={{ height: 220 }}>
<img src={photos[photoIdx]} alt="" className="w-full h-full object-cover" />
<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} onClick={() => setPhotoIdx(i)}
className={cn('w-14 h-14 rounded-lg overflow-hidden border-2 shrink-0', 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-40 rounded-xl border-2 border-dashed border-slate-300 dark:border-slate-600 text-slate-400">
<ImagePlus size={28} className="mb-2 opacity-40" />
<p className="text-sm">Фото категории не загружены</p>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
<button onClick={() => fileRef.current?.click()} className="btn-secondary w-full justify-center">
<ImagePlus size={14} />
Загрузить фото
</button>
</>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!name.trim()}>
{isEdit ? 'Сохранить' : 'Создать категорию'}
</button>
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function RoomCategoriesPage() {
const [categories, setCategories] = useState<RoomCategory[]>(MOCK_CATEGORIES)
const [editingCat, setEditingCat] = useState<RoomCategory | undefined>(undefined)
const [formOpen, setFormOpen] = useState(false)
const openCreate = () => { setEditingCat(undefined); setFormOpen(true) }
const openEdit = (cat: RoomCategory) => { setEditingCat(cat); setFormOpen(true) }
const handleSave = (cat: RoomCategory) => {
setCategories(prev => {
const idx = prev.findIndex(c => c.id === cat.id)
if (idx >= 0) { const next = [...prev]; next[idx] = cat; return next }
return [...prev, cat]
})
setFormOpen(false)
}
const handleDelete = (id: string) =>
setCategories(prev => prev.filter(c => c.id !== id))
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 => {
const roomCount = MOCK_ROOM_COUNTS[cat.id] ?? 0
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>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: cat.color + '20', color: cat.color }}
>
{roomCount} {roomCount === 1 ? 'номер' : roomCount < 5 ? 'номера' : 'номеров'}
</span>
</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 items-center gap-2 shrink-0">
{cat.photos.length > 0 && (
<span className="text-xs text-slate-400">{cat.photos.length} фото</span>
)}
<button
onClick={() => openEdit(cat)}
className="p-2 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-2 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>
<button
onClick={() => openEdit(cat)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
Номера <ChevronRight size={12} />
</button>
</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>
{formOpen && (
<CategoryForm
category={editingCat}
onClose={() => setFormOpen(false)}
onSave={handleSave}
/>
)}
</div>
)
}