- {/* Row 1: Number | Name */}
-
-
- {/* Row 2: Floor | Type */}
-
-
-
- Этаж
-
- set('floor', parseInt(e.target.value) || 1)}
- />
-
-
-
- Тип номера
-
- set('type', e.target.value)}
- >
- {ROOM_TYPES.map(t => (
- {t}
- ))}
-
-
-
-
- {/* Row 3: Bed type | Max guests */}
-
-
-
- Тип кровати
-
- set('bedType', e.target.value as BedType)}
- >
- {BED_TYPES.map(b => (
- {b.label}
- ))}
-
-
-
-
- Макс. гостей
-
- set('maxGuests', parseInt(e.target.value) || 1)}
- />
-
-
-
- {/* Row 4: Base rate | Status */}
-
-
-
- Цена ₽/ночь
-
- set('baseRate', parseInt(e.target.value) || 0)}
- />
-
-
-
- Статус номера
-
- set('status', e.target.value as RoomStatus)}
- >
- {ROOM_STATUSES.map(s => (
- {s.label}
- ))}
-
-
-
-
- {/* Row 5: Housekeeping | Sort order */}
-
-
-
- Статус уборки
-
- set('housekeepingStatus', e.target.value as HousekeepingStatus)}
- >
- {HK_STATUSES.map(s => (
- {s.label}
- ))}
-
-
-
-
- Порядок сортировки
-
- set('sortOrder', parseInt(e.target.value) || 1)}
- />
-
-
-
- {/* Hourly section */}
-
-
-
Почасовая аренда
-
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',
- )}
- >
-
-
-
- {form.allowHourly && (
-
-
- Цена ₽/час
-
- set('hourlyRate', parseInt(e.target.value) || 0)}
- />
-
- )}
- {!form.allowHourly && (
-
-
- Разрешить бронирование номера на несколько часов
-
-
- )}
-
-
- {/* Amenities */}
-
-
- Удобства
-
-
- {AMENITY_LIST.map(a => {
- const selected = amenities.includes(a)
- return (
- 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}
-
- )
- })}
-
-
+ {/* Tab bar */}
+
+ {TABS.map(t => (
+ 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}
+
+ ))}
+
+ {/* ── Main tab ── */}
+ {tab === 'main' && (
+
+
+
+
+
+ Этаж
+ set('floor', parseInt(e.target.value) || 1)} />
+
+
+ Тип номера
+ set('type', e.target.value)}>
+ {ROOM_TYPES.map(t => {t} )}
+
+
+
+
+ {categories.length > 0 && (
+
+ Категория
+ set('categoryId', e.target.value)}>
+ — Без категории —
+ {categories.map(c => {c.name} )}
+
+
+ )}
+
+
+
+ Тип кровати
+ set('bedType', e.target.value as BedType)}>
+ {BED_TYPES.map(b => {b.label} )}
+
+
+
+ Макс. гостей
+ set('maxGuests', parseInt(e.target.value) || 1)} />
+
+
+
+
+
+ Цена ₽/ночь
+ set('baseRate', parseInt(e.target.value) || 0)} />
+
+
+ Статус номера
+ set('status', e.target.value as RoomStatus)}>
+ {ROOM_STATUSES.map(s => {s.label} )}
+
+
+
+
+
+
+ Статус уборки
+ set('housekeepingStatus', e.target.value as HousekeepingStatus)}>
+ {HK_STATUSES.map(s => {s.label} )}
+
+
+
+ Порядок сортировки
+ set('sortOrder', parseInt(e.target.value) || 1)} />
+
+
+
+ {/* Hourly section */}
+
+
+
Почасовая аренда
+
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')}
+ >
+
+
+
+ {form.allowHourly ? (
+
+ Цена ₽/час
+ set('hourlyRate', parseInt(e.target.value) || 0)} />
+
+ ) : (
+
+
Разрешить бронирование номера на несколько часов
+
+ )}
+
+
+ {/* Amenities */}
+
+
Удобства
+
+ {AMENITY_LIST.map(a => {
+ const selected = amenities.includes(a)
+ return (
+ 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}
+
+ )
+ })}
+
+
+
+ )}
+
+ {/* ── Description tab ── */}
+ {tab === 'description' && (
+
+
+
+ Описание номера
+
+
+ Будет отображаться в виджете онлайн-бронирования для гостей.
+
+
+
+ {form.description && (
+
+
Предпросмотр
+
{form.description}
+
+ )}
+
+ )}
+
+ {/* ── Photos tab ── */}
+ {tab === 'photos' && (
+
+ {/* Main photo viewer */}
+ {photos.length > 0 ? (
+
+
+
+ {photos.length > 1 && (
+ <>
+
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"
+ >
+
+
+
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"
+ >
+
+
+ >
+ )}
+
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"
+ >
+
+
+
+ {photoIdx + 1} / {photos.length}
+
+
+
+ {/* Thumbnails */}
+
+ {photos.map((p, i) => (
+
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',
+ )}
+ >
+
+
+ ))}
+
+
+ ) : (
+
+
+
Фотографий пока нет
+
+ )}
+
+ {/* Upload button */}
+
+
fileInputRef.current?.click()}
+ className="btn-secondary w-full justify-center"
+ >
+
+ {photos.length > 0 ? 'Добавить ещё фото' : 'Загрузить фотографии'}
+
+
+ Поддерживаются JPG, PNG, WEBP. Первое фото — обложка в виджете.
+
+
+ )}
)
}
diff --git a/src/data/modulesData.ts b/src/data/modulesData.ts
index 279b603..a939c8b 100644
--- a/src/data/modulesData.ts
+++ b/src/data/modulesData.ts
@@ -1,7 +1,7 @@
import {
Wifi, CreditCard, Tv2, KeyRound,
BarChart3, Globe, CalendarCheck2, Sparkles, Network,
- ShoppingCart, Star, UtensilsCrossed, CalendarClock, ArrowLeftRight,
+ ShoppingCart, Star, UtensilsCrossed, CalendarClock, ArrowLeftRight, FileText,
} from 'lucide-react'
import type { ElementType } from 'react'
@@ -367,6 +367,31 @@ export const MODULES_DATA: ModuleDef[] = [
icon: CalendarClock,
},
},
+ {
+ id: 'documents',
+ name: 'Конструктор документов',
+ tagline: 'Печатные документы для гостей',
+ description:
+ 'Создайте шаблоны регистрационных карт, счетов и договоров с переменными-подстановками. При заселении распечатайте весь пакет или выбранные документы одним кликом.',
+ icon: FileText,
+ iconBg: 'bg-slate-100 dark:bg-slate-900/40',
+ iconColor: 'text-slate-600 dark:text-slate-400',
+ accentColor: 'bg-slate-500',
+ price: 0,
+ badge: 'Входит в тариф',
+ features: [
+ 'Редактор шаблонов с переменными ({guest_name}, {room_number} и др.)',
+ 'Типы: регистрационная карта, счёт, договор, информация',
+ 'Пакет документов при заселении',
+ 'Выборочная и групповая печать',
+ 'Предпросмотр с подстановкой данных гостя',
+ ],
+ sidebarItem: {
+ path: '/documents',
+ label: 'Документы',
+ icon: FileText,
+ },
+ },
{
id: 'migration',
name: 'Миграция данных',
diff --git a/src/pages/DocumentsPage.tsx b/src/pages/DocumentsPage.tsx
new file mode 100644
index 0000000..478abce
--- /dev/null
+++ b/src/pages/DocumentsPage.tsx
@@ -0,0 +1,490 @@
+import { useState } from 'react'
+import {
+ FileText, Plus, Pencil, Trash2, Printer, Copy, CheckCheck,
+ ChevronDown, X as XIcon, Eye,
+} from 'lucide-react'
+import { cn } from '../lib/utils'
+import type { DocumentTemplate } from '../types'
+
+// ── Mock templates ─────────────────────────────────────────────────────────────
+
+const TEMPLATE_TYPES: { value: DocumentTemplate['type']; label: string; icon: string }[] = [
+ { value: 'registration', label: 'Регистрационная карта', icon: '📋' },
+ { value: 'invoice', label: 'Счёт / Квитанция', icon: '🧾' },
+ { value: 'contract', label: 'Договор', icon: '📄' },
+ { value: 'info', label: 'Информационный лист', icon: '📑' },
+]
+
+const VARIABLES = [
+ { var: '{hotel_name}', desc: 'Название отеля' },
+ { var: '{hotel_address}', desc: 'Адрес отеля' },
+ { var: '{guest_name}', desc: 'Имя гостя' },
+ { var: '{guest_email}', desc: 'Email гостя' },
+ { var: '{room_number}', desc: 'Номер комнаты' },
+ { var: '{room_type}', desc: 'Тип номера' },
+ { var: '{check_in}', desc: 'Дата заезда' },
+ { var: '{check_out}', desc: 'Дата выезда' },
+ { var: '{nights}', desc: 'Количество ночей' },
+ { var: '{adults}', desc: 'Кол-во взрослых' },
+ { var: '{children}', desc: 'Кол-во детей' },
+ { var: '{total_amount}', desc: 'Итоговая сумма' },
+ { var: '{paid_amount}', desc: 'Оплачено' },
+ { var: '{balance}', desc: 'Остаток к оплате' },
+ { var: '{today}', desc: 'Сегодняшняя дата' },
+]
+
+const MOCK_TEMPLATES: DocumentTemplate[] = [
+ {
+ id: 'tpl1',
+ hotelId: 'hotel-1',
+ name: 'Регистрационная карта',
+ type: 'registration',
+ forCheckIn: true,
+ printOrder: 1,
+ content: `РЕГИСТРАЦИОННАЯ КАРТА ГОСТЯ
+{hotel_name}
+{hotel_address}
+
+Дата: {today}
+
+Гость: {guest_name}
+Email: {guest_email}
+
+Номер: {room_number} ({room_type})
+Заезд: {check_in}
+Выезд: {check_out}
+Ночей: {nights}
+Гостей: {adults} взр. + {children} дет.
+
+Итого: {total_amount} ₽
+Оплачено: {paid_amount} ₽
+К доплате: {balance} ₽
+
+Подпись гостя: _____________________
+Дата: _______________________________`,
+ },
+ {
+ id: 'tpl2',
+ hotelId: 'hotel-1',
+ name: 'Счёт за проживание',
+ type: 'invoice',
+ forCheckIn: false,
+ printOrder: 2,
+ content: `СЧЁТ ЗА ПРОЖИВАНИЕ
+{hotel_name}
+
+Гость: {guest_name}
+Номер: {room_number}
+Период: {check_in} — {check_out} ({nights} ночей)
+
+ИТОГО К ОПЛАТЕ: {total_amount} ₽
+Оплачено: {paid_amount} ₽
+Остаток: {balance} ₽
+
+Спасибо за выбор нашего отеля!`,
+ },
+ {
+ id: 'tpl3',
+ hotelId: 'hotel-1',
+ name: 'Правила проживания',
+ type: 'info',
+ forCheckIn: true,
+ printOrder: 3,
+ content: `ПРАВИЛА ПРОЖИВАНИЯ
+{hotel_name}
+
+Уважаемый гость, {guest_name}!
+Добро пожаловать в наш отель.
+
+Время заезда: 14:00
+Время выезда: 12:00
+
+Основные правила:
+• Тишина с 22:00 до 08:00
+• Курение запрещено в номерах
+• Домашние животные по согласованию
+• Бассейн и спортзал — с 07:00 до 22:00
+
+Ресепшн работает круглосуточно.
+Звоните: 0 (внутренний номер)
+
+Приятного отдыха!`,
+ },
+]
+
+// ── Document preview with variable substitution ────────────────────────────────
+
+function previewContent(content: string): string {
+ return content
+ .replace(/\{hotel_name\}/g, 'Grand Palace Hotel')
+ .replace(/\{hotel_address\}/g, 'г. Москва, ул. Тверская, 1')
+ .replace(/\{guest_name\}/g, 'Иванов Иван Иванович')
+ .replace(/\{guest_email\}/g, 'ivanov@example.com')
+ .replace(/\{room_number\}/g, '301')
+ .replace(/\{room_type\}/g, 'Пентхаус')
+ .replace(/\{check_in\}/g, '15.03.2026')
+ .replace(/\{check_out\}/g, '18.03.2026')
+ .replace(/\{nights\}/g, '3')
+ .replace(/\{adults\}/g, '2')
+ .replace(/\{children\}/g, '0')
+ .replace(/\{total_amount\}/g, '45 000')
+ .replace(/\{paid_amount\}/g, '22 500')
+ .replace(/\{balance\}/g, '22 500')
+ .replace(/\{today\}/g, '15.03.2026')
+}
+
+// ── Template editor ────────────────────────────────────────────────────────────
+
+interface EditorProps {
+ template?: DocumentTemplate
+ onClose: () => void
+ onSave: (tpl: DocumentTemplate) => void
+}
+
+function TemplateEditor({ template, onClose, onSave }: EditorProps) {
+ const isEdit = !!template
+ const [name, setName] = useState(template?.name ?? '')
+ const [type, setType] = useState
(template?.type ?? 'registration')
+ const [content, setContent] = useState(template?.content ?? '')
+ const [forCheckIn, setForCheckIn] = useState(template?.forCheckIn ?? false)
+ const [preview, setPreview] = useState(false)
+ const [copiedVar, setCopiedVar] = useState(null)
+
+ const insertVar = (v: string) => {
+ const el = document.getElementById('tpl-content') as HTMLTextAreaElement
+ if (!el) { setContent(c => c + v); return }
+ const start = el.selectionStart ?? content.length
+ const end = el.selectionEnd ?? content.length
+ const next = content.slice(0, start) + v + content.slice(end)
+ setContent(next)
+ setTimeout(() => { el.focus(); el.setSelectionRange(start + v.length, start + v.length) }, 10)
+ }
+
+ const handleSave = () => {
+ if (!name.trim()) return
+ onSave({
+ id: template?.id ?? `tpl-${Date.now()}`,
+ hotelId: template?.hotelId ?? 'hotel-1',
+ name: name.trim(),
+ type,
+ content,
+ forCheckIn,
+ printOrder: template?.printOrder ?? 99,
+ })
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {isEdit ? `Редактировать: ${template.name}` : 'Новый шаблон документа'}
+
+
+ setPreview(p => !p)}
+ className={cn('flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
+ preview ? 'bg-brand-600 text-white border-brand-600' : 'border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300',
+ )}
+ >
+ {preview ? 'Редактор' : 'Предпросмотр'}
+
+
+
+
+
+
+
+
+ {/* Left: editor */}
+
+ {/* Meta */}
+
+
+
+ Название
+ setName(e.target.value)} />
+
+
+ Тип документа
+ setType(e.target.value as DocumentTemplate['type'])}>
+ {TEMPLATE_TYPES.map(t => {t.icon} {t.label} )}
+
+
+
+
+ setForCheckIn(e.target.checked)} className="rounded" />
+ Печатать при заселении (добавить в пакет документов)
+
+
+
+ {/* Content area */}
+
+ {preview ? (
+
+
+ {previewContent(content)}
+
+
+ ) : (
+
+
+
+ {/* Right: variables panel */}
+
+
+
Переменные
+
Нажмите для вставки
+
+
+ {VARIABLES.map(v => (
+
{
+ insertVar(v.var)
+ setCopiedVar(v.var)
+ setTimeout(() => setCopiedVar(null), 1500)
+ }}
+ className="w-full text-left p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors group"
+ >
+
+ {copiedVar === v.var ? : }
+ {v.var}
+
+ {v.desc}
+
+ ))}
+
+
+
+
+ {/* Footer */}
+
+ Отмена
+
+ {isEdit ? 'Сохранить' : 'Создать шаблон'}
+
+
+
+
+ )
+}
+
+// ── Print package selector (used at check-in) ────────────────────────────────
+
+export function PrintPackageDropdown({
+ templates,
+ onPrint,
+}: {
+ templates: DocumentTemplate[]
+ onPrint: (ids: string[]) => void
+}) {
+ const [open, setOpen] = useState(false)
+ const [selected, setSelected] = useState(
+ templates.filter(t => t.forCheckIn).map(t => t.id),
+ )
+
+ const toggle = (id: string) =>
+ setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
+
+ return (
+
+
setOpen(v => !v)}
+ className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-300 hover:border-brand-400 transition-colors"
+ >
+
+ Печать документов
+
+
+
+ {open && (
+ <>
+
setOpen(false)} />
+
+
+
Выберите документы для печати
+
+
+ {templates.map(t => {
+ const tplType = TEMPLATE_TYPES.find(x => x.value === t.type)
+ return (
+
+ toggle(t.id)}
+ className="rounded"
+ />
+ {tplType?.icon ?? '📄'}
+
+
{t.name}
+ {t.forCheckIn && (
+
При заселении
+ )}
+
+
+ )
+ })}
+
+
+
{ onPrint(selected); setOpen(false) }}
+ disabled={selected.length === 0}
+ className="btn-primary flex-1 justify-center disabled:opacity-40"
+ >
+
+ Печать ({selected.length})
+
+
{ onPrint(templates.map(t => t.id)); setOpen(false) }}
+ className="btn-secondary text-xs"
+ >
+ Все
+
+
+
+ >
+ )}
+
+ )
+}
+
+// ── Main page ─────────────────────────────────────────────────────────────────
+
+export function DocumentsPage() {
+ const [templates, setTemplates] = useState
(MOCK_TEMPLATES)
+ const [editingTpl, setEditingTpl] = useState()
+ const [editorOpen, setEditorOpen] = useState(false)
+ const [printedId, setPrintedId] = useState(null)
+
+ const openCreate = () => { setEditingTpl(undefined); setEditorOpen(true) }
+ const openEdit = (tpl: DocumentTemplate) => { setEditingTpl(tpl); setEditorOpen(true) }
+
+ const handleSave = (tpl: DocumentTemplate) => {
+ setTemplates(prev => {
+ const idx = prev.findIndex(t => t.id === tpl.id)
+ if (idx >= 0) { const next = [...prev]; next[idx] = tpl; return next }
+ return [...prev, tpl]
+ })
+ setEditorOpen(false)
+ }
+
+ const handleDelete = (id: string) => setTemplates(prev => prev.filter(t => t.id !== id))
+
+ const handlePrint = (ids: string[]) => {
+ // In production: trigger print dialog with generated PDF
+ alert(`Печать документов: ${ids.map(id => templates.find(t => t.id === id)?.name).join(', ')}`)
+ }
+
+ const checkInTemplates = templates.filter(t => t.forCheckIn)
+
+ return (
+
+ {/* Header */}
+
+
+
Документы
+
Конструктор шаблонов для печати при заселении и выезде
+
+
+
+
+ {/* Check-in package info */}
+ {checkInTemplates.length > 0 && (
+
+
+
+
+
+
Пакет документов при заселении
+
+ {checkInTemplates.map(t => t.name).join(' · ')}
+
+
+
+
handlePrint(checkInTemplates.map(t => t.id))}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 transition-colors"
+ >
+
+ Напечатать все
+
+
+
+ )}
+
+ {/* Templates list */}
+
+ {templates.map(tpl => {
+ const tplType = TEMPLATE_TYPES.find(t => t.value === tpl.type)
+ return (
+
+
+
+ {tplType?.icon ?? '📄'}
+
+
+
+
{tpl.name}
+ {tpl.forCheckIn && (
+
+ При заселении
+
+ )}
+
+
{tplType?.label}
+
{tpl.content.slice(0, 80)}…
+
+
+
{ setPrintedId(tpl.id); handlePrint([tpl.id]); setTimeout(() => setPrintedId(null), 2000) }}
+ 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"
+ title="Напечатать"
+ >
+ {printedId === tpl.id ? : }
+
+
openEdit(tpl)}
+ 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"
+ >
+
+
+
handleDelete(tpl.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"
+ >
+
+
+
+
+
+ )
+ })}
+
+
+ {editorOpen && (
+
setEditorOpen(false)}
+ onSave={handleSave}
+ />
+ )}
+
+ )
+}
diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx
index 5ebbf5c..d6a186c 100644
--- a/src/pages/ReviewsPage.tsx
+++ b/src/pages/ReviewsPage.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react'
import {
- Star, ThumbsUp, ThumbsDown, ExternalLink, Copy, CheckCheck,
- MessageSquare, QrCode, ArrowUpRight,
+ Star, ThumbsDown, Copy, CheckCheck, MessageSquare, QrCode,
+ ArrowUpRight, Plus, Trash2, ExternalLink, Settings2,
} from 'lucide-react'
import { format, subDays } from 'date-fns'
import { ru } from 'date-fns/locale'
@@ -10,139 +10,124 @@ import { Badge } from '../components/ui/Badge'
// ── Types ─────────────────────────────────────────────────────────────────────
-// negative (< 2 stars) → 'pending' (need manager reply)
-// positive (≥ 2 stars) → 'redirect_pending' (send to external platforms)
+// pending = negative review (< threshold), awaiting manager reply
+// redirect_pending = positive review, will be auto-redirected to platforms
+// published = manager replied / redirected
+// rejected = spam
type ReviewStatus = 'pending' | 'redirect_pending' | 'published' | 'rejected'
interface Review {
id: string
guestName: string
roomNumber: string
- checkOut: string
rating: number // 1–10
text: string
status: ReviewStatus
source: 'qr' | 'email' | 'sms'
createdAt: Date
reply?: string
- redirectedTo?: string[]
+ autoRedirectedTo?: string[]
}
+interface ReviewPlatform {
+ id: string
+ name: string
+ url: string
+ enabled: boolean
+}
+
+// ── Default settings ──────────────────────────────────────────────────────────
+
+const DEFAULT_PLATFORMS: ReviewPlatform[] = [
+ { id: 'p1', name: 'Booking.com', url: 'https://www.booking.com/hotel/ru/', enabled: true },
+ { id: 'p2', name: 'Яндекс Путешествия', url: 'https://travel.yandex.ru/hotels/', enabled: true },
+ { id: 'p3', name: 'Google', url: 'https://g.page/r/', enabled: true },
+ { id: 'p4', name: '2ГИС', url: 'https://2gis.ru/moscow/firm/', enabled: false },
+]
+
// ── Mock data ─────────────────────────────────────────────────────────────────
-// NEGATIVE_THRESHOLD: rating < 4 (= < 2 stars out of 5) → moderation
-const NEGATIVE_THRESHOLD = 4
-
const MOCK_REVIEWS: Review[] = [
{
- id: 'rv1',
- guestName: 'Дмитрий Волков',
- roomNumber: '101',
- checkOut: format(subDays(new Date(), 1), 'yyyy-MM-dd'),
- rating: 9,
- text: 'Замечательный отель! Персонал очень вежливый и отзывчивый. Номер чистый, завтрак вкусный. Обязательно вернёмся.',
+ id: 'rv1', guestName: 'Дмитрий Волков', roomNumber: '101',
+ rating: 9, source: 'email', createdAt: new Date(Date.now() - 3600000 * 5),
+ text: 'Замечательный отель! Персонал очень вежливый. Номер чистый, завтрак вкусный. Обязательно вернёмся.',
status: 'redirect_pending',
- source: 'email',
- createdAt: new Date(Date.now() - 3600000 * 5),
},
{
- id: 'rv2',
- guestName: 'Анна Козлова',
- roomNumber: '202',
- checkOut: format(subDays(new Date(), 2), 'yyyy-MM-dd'),
- rating: 2,
- text: 'Шум из соседнего номера мешал спать. Кондиционер плохо работал. Персонал помог, но проблему до конца не решили.',
+ id: 'rv2', guestName: 'Анна Козлова', roomNumber: '202',
+ rating: 2, source: 'qr', createdAt: new Date(Date.now() - 3600000 * 12),
+ text: 'Шум из соседнего номера мешал спать. Кондиционер плохо работал. Разочарована.',
status: 'pending',
- source: 'qr',
- createdAt: new Date(Date.now() - 3600000 * 12),
},
{
- id: 'rv3',
- guestName: 'Наталья Александрова',
- roomNumber: '301',
- checkOut: format(subDays(new Date(), 3), 'yyyy-MM-dd'),
- rating: 10,
- text: 'Лучший отель в котором я останавливалась! Вид из окна потрясающий, кровать удобная, всё очень стильно.',
+ id: 'rv3', guestName: 'Наталья Александрова', roomNumber: '301',
+ rating: 10, source: 'sms', createdAt: new Date(Date.now() - 3600000 * 36),
+ text: 'Лучший отель! Вид из окна потрясающий, кровать удобная, всё стильно.',
status: 'published',
- source: 'sms',
- createdAt: new Date(Date.now() - 3600000 * 36),
- redirectedTo: ['Google', 'Яндекс'],
+ autoRedirectedTo: ['Google', 'Яндекс Путешествия'],
},
{
- id: 'rv4',
- guestName: 'Игорь Соколов',
- roomNumber: '201',
- checkOut: format(subDays(new Date(), 5), 'yyyy-MM-dd'),
- rating: 7,
- text: 'В целом хорошо. Немного дорого для такого номера. Но расположение отличное, добираться удобно.',
+ id: 'rv4', guestName: 'Игорь Соколов', roomNumber: '201',
+ rating: 7, source: 'email', createdAt: new Date(Date.now() - 3600000 * 48),
+ text: 'В целом хорошо. Немного дорого для такого номера. Расположение отличное.',
status: 'redirect_pending',
- source: 'email',
- createdAt: new Date(Date.now() - 3600000 * 48),
},
{
- id: 'rv5',
- guestName: 'Виктор Громов',
- roomNumber: '402',
- checkOut: format(subDays(new Date(), 6), 'yyyy-MM-dd'),
- rating: 3,
- text: 'Долго ждали заселения. Номер был убран с опозданием. Разочарованы.',
+ id: 'rv5', guestName: 'Виктор Громов', roomNumber: '402',
+ rating: 3, source: 'qr', createdAt: new Date(Date.now() - 3600000 * 60),
+ text: 'Долго ждали заселения. Номер убран с опозданием. Разочарованы.',
status: 'pending',
- source: 'qr',
- createdAt: new Date(Date.now() - 3600000 * 60),
},
{
- id: 'rv6',
- guestName: 'Михаил Орлов',
- roomNumber: '401',
- checkOut: format(subDays(new Date(), 8), 'yyyy-MM-dd'),
- rating: 8,
- text: 'Очень доволен пребыванием. Всё было организованно на высшем уровне. Спасибо персоналу.',
+ id: 'rv6', guestName: 'Михаил Орлов', roomNumber: '401',
+ rating: 8, source: 'email', createdAt: new Date(Date.now() - 3600000 * 100),
+ text: 'Очень доволен. Персонал внимательный, номер чистый.',
status: 'published',
- source: 'email',
- createdAt: new Date(Date.now() - 3600000 * 100),
- redirectedTo: ['Booking.com'],
+ reply: 'Михаил, спасибо за тёплые слова! Ждём вас снова.',
},
]
const REVIEW_LINK = 'https://app.hotelsync.ru/review/grand-palace'
-const PLATFORMS = ['Booking.com', 'Яндекс Путешествия', 'Google', '2ГИС']
-
function StarRating({ rating, max = 10, size = 14 }: { rating: number; max?: number; size?: number }) {
const stars = Math.round((rating / max) * 5)
return (
{Array.from({ length: 5 }, (_, i) => (
-
+
))}
{rating}/10
)
}
-function ratingColor(r: number): string {
+function ratingColor(r: number, threshold: number): string {
if (r >= 9) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
if (r >= 7) return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
- if (r >= NEGATIVE_THRESHOLD) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
+ if (r >= threshold) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
}
-const SOURCE_LABEL: Record = {
- qr: 'QR-код', email: 'Email', sms: 'SMS',
-}
+const SOURCE_LABEL: Record = { qr: 'QR-код', email: 'Email', sms: 'SMS' }
-type Tab = 'pending' | 'redirect_pending' | 'published' | 'all'
+type Tab = 'pending' | 'redirect_pending' | 'published' | 'all' | 'settings'
+
+// ── Component ─────────────────────────────────────────────────────────────────
export function ReviewsPage() {
- const [reviews, setReviews] = useState(MOCK_REVIEWS)
- const [tab, setTab] = useState('pending')
- const [replyId, setReplyId] = useState(null)
- const [replyText, setReplyText] = useState('')
- const [copied, setCopied] = useState(false)
+ const [reviews, setReviews] = useState(MOCK_REVIEWS)
+ const [tab, setTab] = useState('pending')
+ const [replyId, setReplyId] = useState(null)
+ const [replyText, setReplyText] = useState('')
+ const [copied, setCopied] = useState(false)
+
+ // Settings state
+ // Threshold: rating OUT OF 10. Default = 6 (=3 stars out of 5)
+ const [threshold, setThreshold] = useState(6)
+ const [platforms, setPlatforms] = useState(DEFAULT_PLATFORMS)
+ const [newPlatformName, setNewPlatformName] = useState('')
+ const [newPlatformUrl, setNewPlatformUrl] = useState('')
const pending = reviews.filter(r => r.status === 'pending')
const redirectPending = reviews.filter(r => r.status === 'redirect_pending')
@@ -151,7 +136,6 @@ export function ReviewsPage() {
const avgRating = published.length
? (published.reduce((s, r) => s + r.rating, 0) / published.length).toFixed(1)
: '—'
-
const nps = published.length
? Math.round(
(published.filter(r => r.rating >= 9).length / published.length * 100) -
@@ -159,7 +143,9 @@ export function ReviewsPage() {
)
: 0
- const filtered = tab === 'all' ? reviews : reviews.filter(r => r.status === tab)
+ const filtered = tab === 'all' ? reviews
+ : tab === 'settings' ? []
+ : reviews.filter(r => r.status === tab)
const sendReply = (id: string) => {
if (!replyText.trim()) return
@@ -173,18 +159,16 @@ export function ReviewsPage() {
const reject = (id: string) =>
setReviews(prev => prev.map(r => r.id === id ? { ...r, status: 'rejected' } : r))
- const sendToPlatform = (id: string, platform: string) => {
- setReviews(prev => prev.map(r => {
- if (r.id !== id) return r
- const already = r.redirectedTo ?? []
- if (already.includes(platform)) return r
- const redirectedTo = [...already, platform]
- return {
- ...r,
- redirectedTo,
- status: 'published' as ReviewStatus,
- }
- }))
+ const addPlatform = () => {
+ if (!newPlatformName.trim()) return
+ setPlatforms(prev => [...prev, {
+ id: `p-${Date.now()}`,
+ name: newPlatformName.trim(),
+ url: newPlatformUrl.trim(),
+ enabled: true,
+ }])
+ setNewPlatformName('')
+ setNewPlatformUrl('')
}
const copyLink = () => {
@@ -193,11 +177,16 @@ export function ReviewsPage() {
setTimeout(() => setCopied(false), 2000)
}
- const tabs: { key: Tab; label: string; count?: number; color?: string }[] = [
- { key: 'pending', label: 'Требуют ответа', count: pending.length, color: 'red' },
- { key: 'redirect_pending', label: 'Перенаправить', count: redirectPending.length, color: 'blue' },
- { key: 'published', label: 'Опубликованы', count: published.length },
+ const enabledPlatforms = platforms.filter(p => p.enabled)
+
+ const thresholdStars = Math.round((threshold / 10) * 5)
+
+ const tabs: { key: Tab; label: string; count?: number; colorCls?: string }[] = [
+ { key: 'pending', label: 'Требуют ответа', count: pending.length, colorCls: 'bg-red-500 text-white' },
+ { key: 'redirect_pending', label: 'Ожидают редирект', count: redirectPending.length, colorCls: 'bg-blue-500 text-white' },
+ { key: 'published', label: 'Архив', count: published.length },
{ key: 'all', label: 'Все' },
+ { key: 'settings', label: 'Настройки' },
]
return (
@@ -206,17 +195,18 @@ export function ReviewsPage() {
Отзывы гостей
- Отрицательные отзывы (<2 звёзд) — модерация и личный ответ. Положительные — перенаправление на площадки.
+ Отрицательные (< {thresholdStars} звёзд) → модерация с личным ответом.
+ Положительные → автоматическое перенаправление на площадки.
{/* Stats */}
{[
- { label: 'Средняя оценка', value: avgRating, sub: 'из 10', color: 'text-amber-600 dark:text-amber-400' },
+ { label: 'Средняя оценка', value: avgRating, sub: 'из 10', color: 'text-amber-600 dark:text-amber-400' },
{ label: 'NPS', value: nps >= 0 ? `+${nps}` : `${nps}`, sub: 'индекс лояльности', color: 'text-emerald-600 dark:text-emerald-400' },
- { label: 'Опубликовано', value: published.length, sub: 'отзывов', color: 'text-brand-600 dark:text-brand-400' },
- { label: 'Ждут ответа', value: pending.length, sub: 'негативных', color: pending.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-500' },
+ { label: 'В архиве', value: published.length, sub: 'отзывов', color: 'text-brand-600 dark:text-brand-400' },
+ { label: 'Ждут ответа', value: pending.length, sub: 'негативных', color: pending.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-500' },
].map(s => (
{s.value}
@@ -226,55 +216,38 @@ export function ReviewsPage() {
))}
- {/* Logic explanation banner */}
+ {/* Logic banners */}
-
-
-
-
-
-
-
Отрицательные (< 2 звёзд)
-
- Попадают на модерацию. Менеджер пишет личный ответ — гость получает его по email/SMS.
- На публичные площадки не публикуется.
-
-
+
+
+
+
Отрицательные (< {thresholdStars} звёзд)
+
+ Попадают на модерацию. Менеджер пишет личный ответ, который отправляется гостю по email/SMS. Публично не публикуется.
+
-
-
-
-
-
Положительные (≥ 2 звёзд)
-
- Предлагаем гостю оставить отзыв на внешней площадке: Booking.com, Google, Яндекс и т.д.
-
-
+
+
+
+
Положительные (≥ {thresholdStars} звёзд)
+
+ Система автоматически предлагает гостю оставить отзыв на: {enabledPlatforms.map(p => p.name).join(', ') || '(настройте площадки)'}
+
- {/* Public review link */}
+ {/* Review link */}
-
-
-
-
+
+
-
Ссылка для сбора отзывов
-
- Отправьте гостю при выезде или разместите QR-код в номере
-
-
-
{REVIEW_LINK}
-
- {copied ? : }
+ Ссылка для сбора отзывов
+
+ {REVIEW_LINK}
+
+ {copied ? : }
{copied ? 'Скопировано' : 'Копировать'}
@@ -289,20 +262,16 @@ export function ReviewsPage() {
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
- 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5 shrink-0',
+ 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5 shrink-0 whitespace-nowrap',
tab === t.key
? 'border-brand-600 text-brand-600 dark:text-brand-400'
: 'border-transparent text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
)}
>
+ {t.key === 'settings' && }
{t.label}
{t.count !== undefined && t.count > 0 && (
-
+
{t.count}
)}
@@ -310,146 +279,246 @@ export function ReviewsPage() {
))}
- {/* Reviews list */}
-
- {filtered.length === 0 && (
-
- )}
- {filtered.map(review => {
- const isNegative = review.rating < NEGATIVE_THRESHOLD
- return (
-
-
- {/* Rating circle */}
-
- {review.rating}
-
-
-
-
-
-
{review.guestName}
-
№{review.roomNumber}
-
{SOURCE_LABEL[review.source]}
- {isNegative &&
Негативный }
- {!isNegative && review.redirectedTo && review.redirectedTo.length > 0 && (
-
- → {review.redirectedTo.join(', ')}
-
- )}
-
-
{format(review.createdAt, 'd MMM, HH:mm', { locale: ru })}
-
-
-
{review.text}
-
- {/* Manager reply */}
- {review.reply && (
-
-
Личный ответ менеджера (отправлен гостю):
-
{review.reply}
-
- )}
-
- {/* Reply input (for negative) */}
- {replyId === review.id && (
-
- )}
-
- {/* Redirect buttons (for positive) */}
- {review.status === 'redirect_pending' && (
-
-
Перенаправить гостя на площадку:
-
- {PLATFORMS.map(p => {
- const sent = review.redirectedTo?.includes(p)
- return (
- sendToPlatform(review.id, p)}
- disabled={!!sent}
- className={cn(
- 'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
- sent
- ? 'bg-emerald-100 border-emerald-300 text-emerald-700 dark:bg-emerald-900/30 dark:border-emerald-700 dark:text-emerald-300 cursor-default'
- : 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-blue-400',
- )}
- >
- {sent ? : }
- {p}
-
- )
- })}
-
-
- )}
-
-
- {/* Actions */}
-
- {review.status === 'pending' && !review.reply && replyId !== review.id && (
- <>
-
{ setReplyId(review.id); setReplyText('') }}
- className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors"
- >
-
- Ответить гостю
-
-
reject(review.id)}
- className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs font-medium hover:bg-red-200 transition-colors"
- >
-
- Отклонить
-
- >
- )}
- {review.status === 'published' && !review.reply && replyId !== review.id && isNegative && (
-
{ setReplyId(review.id); setReplyText('') }}
- className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 text-xs font-medium hover:border-brand-400 transition-colors"
- >
-
- Ответить
-
- )}
- {review.status === 'rejected' && (
-
setReviews(prev => prev.map(r => r.id === review.id ? { ...r, status: isNegative ? 'pending' : 'redirect_pending' } : r))}
- className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-xs font-medium text-slate-600 dark:text-slate-400 hover:border-brand-400"
- >
- Восстановить
-
- )}
+ {/* ── Settings tab ── */}
+ {tab === 'settings' && (
+
+ {/* Threshold */}
+
+
Порог перенаправления
+
+ Отзывы с оценкой ниже этого порога уходят на модерацию (личный ответ менеджера).
+ Выше — гость получает ссылку на внешние площадки.
+
+
+
+
Порог (из 10 баллов)
+
setThreshold(parseInt(e.target.value))}
+ className="w-48"
+ />
+
+
+
{threshold}
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
{thresholdStars} из 5 звёзд
- )
- })}
-
+
+
+
Рейтинг < {threshold}/10
+
→ Модерация + личный ответ
+
+
+
Рейтинг ≥ {threshold}/10
+
→ Авторедирект на площадки
+
+
+
+
+ {/* Platforms */}
+
+
Площадки для перенаправления
+
+ Гость с положительным отзывом получит ссылки на эти площадки для публикации.
+
+
+ {platforms.map(p => (
+
+
setPlatforms(prev => prev.map(x => x.id === p.id ? { ...x, enabled: !x.enabled } : x))}
+ className={cn('relative w-9 h-5 rounded-full transition-colors shrink-0', p.enabled ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
+ >
+
+
+
+
{p.name}
+
{p.url || 'URL не указан'}
+
+ {p.url && (
+
+
+
+ )}
+
setPlatforms(prev => prev.filter(x => x.id !== p.id))}
+ className="p-1.5 text-slate-400 hover:text-red-500 transition-colors"
+ >
+
+
+
+ ))}
+
+
+ {/* Add platform */}
+
+
+
+ )}
+
+ {/* ── Review list ── */}
+ {tab !== 'settings' && (
+
+ {filtered.length === 0 && (
+
+ )}
+ {filtered.map(review => {
+ const isNegative = review.rating < threshold
+ return (
+
+
+
+ {review.rating}
+
+
+
+
+
+
{review.guestName}
+
№{review.roomNumber}
+
{SOURCE_LABEL[review.source]}
+ {isNegative &&
Негативный }
+ {!isNegative && review.status === 'redirect_pending' && (
+
+ Авторедирект
+
+ )}
+ {review.autoRedirectedTo && review.autoRedirectedTo.length > 0 && (
+
+ → {review.autoRedirectedTo.join(', ')}
+
+ )}
+
+
{format(review.createdAt, 'd MMM, HH:mm', { locale: ru })}
+
+
+
+
{review.text}
+
+ {/* Auto-redirect notice */}
+ {review.status === 'redirect_pending' && (
+
+
+ Гость получит ссылки для публикации на: {enabledPlatforms.map(p => p.name).join(', ')}
+
+
+ )}
+
+ {/* Manager reply */}
+ {review.reply && (
+
+
Личный ответ менеджера:
+
{review.reply}
+
+ )}
+
+ {replyId === review.id && (
+
+ )}
+
+
+ {/* Actions */}
+
+ {review.status === 'pending' && !review.reply && replyId !== review.id && (
+ <>
+
{ setReplyId(review.id); setReplyText('') }}
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors"
+ >
+ Ответить гостю
+
+
reject(review.id)}
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs font-medium hover:bg-red-200 transition-colors"
+ >
+ Отклонить
+
+ >
+ )}
+ {review.status === 'redirect_pending' && (
+
setReviews(prev => prev.map(r => r.id === review.id
+ ? { ...r, status: 'published', autoRedirectedTo: enabledPlatforms.map(p => p.name) }
+ : r,
+ ))}
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 transition-colors"
+ >
+ Отправить ссылки гостю
+
+ )}
+ {review.status === 'rejected' && (
+
setReviews(prev => prev.map(r => r.id === review.id
+ ? { ...r, status: isNegative ? 'pending' : 'redirect_pending' }
+ : r,
+ ))}
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-xs font-medium text-slate-600 dark:text-slate-400 hover:border-brand-400"
+ >
+ Восстановить
+
+ )}
+
+
+
+ )
+ })}
+
+ )}
)
}
diff --git a/src/pages/RoomCategoriesPage.tsx b/src/pages/RoomCategoriesPage.tsx
new file mode 100644
index 0000000..bef916d
--- /dev/null
+++ b/src/pages/RoomCategoriesPage.tsx
@@ -0,0 +1,405 @@
+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
= {
+ 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(category?.amenities ?? [])
+ const [photos, setPhotos] = useState(category?.photos ?? [])
+ const [photoIdx, setPhotoIdx] = useState(0)
+ const fileRef = useRef(null)
+
+ const toggleAmenity = (a: string) =>
+ setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
+
+ const handlePhotoUpload = (e: React.ChangeEvent) => {
+ 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 (
+
+
+
+ {/* Header */}
+
+
+ {isEdit ? `Редактировать: ${category.name}` : 'Новая категория'}
+
+
+
+
+
+
+ {/* Tab bar */}
+
+
+ {(['main', 'photos'] as const).map(t => (
+ 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})` : ''}`}
+
+ ))}
+
+
+
+ {/* Body */}
+
+ {tab === 'main' && (
+ <>
+
+ Название категории *
+ setName(e.target.value)} />
+
+
+
+
Цвет категории
+
+ {CATEGORY_COLORS.map(c => (
+ 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 }}
+ />
+ ))}
+ setColor(e.target.value)} className="w-8 h-8 rounded-full cursor-pointer" />
+
+
+
+
+
Описание
+
Отображается в виджете бронирования как описание категории
+
+
+
+
Базовые удобства
+
+ {AMENITY_LIST.map(a => {
+ const sel = amenities.includes(a)
+ return (
+ 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}
+
+ )
+ })}
+
+
+ >
+ )}
+
+ {tab === 'photos' && (
+ <>
+ {photos.length > 0 ? (
+
+
+
+
removePhoto(photoIdx)}
+ className="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-600 text-white flex items-center justify-center"
+ >
+
+
+
+ {photoIdx + 1} / {photos.length}
+
+
+
+ {photos.map((p, i) => (
+
setPhotoIdx(i)}
+ className={cn('w-14 h-14 rounded-lg overflow-hidden border-2 shrink-0', i === photoIdx ? 'border-brand-500' : 'border-transparent')}>
+
+
+ ))}
+
+
+ ) : (
+
+
+
Фото категории не загружены
+
+ )}
+
+
fileRef.current?.click()} className="btn-secondary w-full justify-center">
+
+ Загрузить фото
+
+ >
+ )}
+
+
+ {/* Footer */}
+
+ Отмена
+
+ {isEdit ? 'Сохранить' : 'Создать категорию'}
+
+
+
+
+ )
+}
+
+// ── Main page ─────────────────────────────────────────────────────────────────
+
+export function RoomCategoriesPage() {
+ const [categories, setCategories] = useState(MOCK_CATEGORIES)
+ const [editingCat, setEditingCat] = useState(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 (
+
+ {/* Header */}
+
+
+
Категории номеров
+
+ Группировка номеров для виджета бронирования и систематизации предложений
+
+
+
+ Добавить категорию
+
+
+
+ {/* Explanation */}
+
+
+
+
+
Как работают категории
+
+ Категории позволяют объединять номера в группы (Стандарт, Делюкс, Люкс).
+ В виджете бронирования гость выбирает категорию, а не конкретный номер.
+ Категория содержит описание и фотогалерею, которые видят гости на сайте.
+
+
+
+
+
+ {/* Categories list */}
+
+ {categories.map(cat => {
+ const roomCount = MOCK_ROOM_COUNTS[cat.id] ?? 0
+ return (
+
+
+ {/* Color indicator + photo */}
+
0 ? undefined : cat.color + '20', borderColor: cat.color, borderWidth: 2 }}
+ >
+ {cat.photos.length > 0
+ ?
+ :
+ }
+
+
+
+
+
{cat.name}
+
+ {roomCount} {roomCount === 1 ? 'номер' : roomCount < 5 ? 'номера' : 'номеров'}
+
+
+
+ {cat.description && (
+
{cat.description}
+ )}
+
+ {/* Amenities */}
+ {cat.amenities.length > 0 && (
+
+ {cat.amenities.map(a => (
+
+ {a}
+
+ ))}
+
+ )}
+
+
+ {/* Actions */}
+
+ {cat.photos.length > 0 && (
+
{cat.photos.length} фото
+ )}
+
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"
+ >
+
+
+
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"
+ >
+
+
+
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"
+ >
+ Номера
+
+
+
+
+ )
+ })}
+
+ {categories.length === 0 && (
+
+
+
Категории не созданы
+
Создайте первую категорию для группировки номеров
+
+ Создать категорию
+
+
+ )}
+
+
+ {formOpen && (
+
setFormOpen(false)}
+ onSave={handleSave}
+ />
+ )}
+
+ )
+}
diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx
index d39e849..7d846a3 100644
--- a/src/pages/RoomsPage.tsx
+++ b/src/pages/RoomsPage.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { BedDouble, Users, Wifi, Plus, Search, Pencil } from 'lucide-react'
import { MOCK_ROOMS } from '../data/mockData'
+import { MOCK_CATEGORIES } from './RoomCategoriesPage'
import type { Room } from '../types'
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
@@ -135,6 +136,7 @@ export function RoomsPage() {
room={editingRoom}
onClose={() => setModalOpen(false)}
onSave={handleSave}
+ categories={MOCK_CATEGORIES}
/>
)
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 5baa883..a45b421 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare } from 'lucide-react'
+import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Users, Plus, X as XIcon } from 'lucide-react'
import { MOCK_HOTELS } from '../data/mockData'
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
@@ -8,6 +8,7 @@ import { useTheme } from '../contexts/ThemeContext'
const SECTIONS = [
{ id: 'general', label: 'Основные', icon: Building2 },
{ id: 'booking', label: 'Бронирование', icon: BedDouble },
+ { id: 'guests', label: 'Гости', icon: Users },
{ id: 'theme', label: 'Внешний вид', icon: Globe },
{ id: 'notify', label: 'Уведомления', icon: Bell },
{ id: 'security', label: 'Безопасность', icon: Shield },
@@ -42,6 +43,19 @@ export function SettingsPage() {
// Booking / assignment settings
const [assignmentStrategy, setAssignmentStrategy] = useState<'spread' | 'together' | 'sequential' | 'manual'>('spread')
+ const [showBookingSource, setShowBookingSource] = useState(false)
+
+ // Guest settings
+ const [guestTags, setGuestTags] = useState([
+ { id: 'vip', label: 'VIP', color: '#F59E0B' },
+ { id: 'regular', label: 'Постоянный гость', color: '#3B82F6' },
+ { id: 'corp', label: 'Корпоративный', color: '#8B5CF6' },
+ { id: 'honey', label: 'Медовый месяц', color: '#EC4899' },
+ { id: 'bday', label: 'День рождения', color: '#10B981' },
+ { id: 'special', label: 'Особые пожелания', color: '#64748B' },
+ ])
+ const [newTagLabel, setNewTagLabel] = useState('')
+ const [newTagColor, setNewTagColor] = useState('#4F46E5')
// Notification toggles
const [notifyToggles, setNotifyToggles] = useState({
@@ -161,6 +175,15 @@ export function SettingsPage() {
<>
Настройки бронирования
+ {/* Source toggle */}
+
+
+
Показывать поле «Источник бронирования»
+
В форме создания/редактирования бронирования
+
+
setShowBookingSource(p => !p)} />
+
+
Стратегия автоматического расселения
@@ -224,6 +247,95 @@ export function SettingsPage() {
>
)}
+ {/* ── GUESTS ── */}
+ {section === 'guests' && (
+ <>
+ Статусы гостей
+
+ Теги отображаются при создании и редактировании бронирования
+
+
+ {/* Tag list */}
+
+
+ {/* Add new tag */}
+
+
+ {/* Preview */}
+
+
Предпросмотр тегов
+
+ {guestTags.map(tag => (
+
+ {tag.label}
+
+ ))}
+
+
+ >
+ )}
+
{/* ── THEME ── */}
{section === 'theme' && (
<>
diff --git a/src/types/index.ts b/src/types/index.ts
index 013f9c4..5877db8 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -42,6 +42,16 @@ export type RoomStatus = 'available' | 'occupied' | 'maintenance' | 'blocked'
export type HousekeepingStatus = 'clean' | 'dirty' | 'cleaning' | 'inspect'
export type BedType = 'single' | 'double' | 'queen' | 'king' | 'twin'
+export interface RoomCategory {
+ id: string
+ hotelId: string
+ name: string
+ description: string
+ photos: string[]
+ color: string
+ amenities: string[]
+}
+
export interface Room {
id: string
hotelId: string
@@ -58,6 +68,19 @@ export interface Room {
sortOrder: number
allowHourly?: boolean
hourlyRate?: number
+ description?: string
+ photos?: string[]
+ categoryId?: string
+}
+
+export interface DocumentTemplate {
+ id: string
+ hotelId: string
+ name: string
+ type: 'registration' | 'invoice' | 'contract' | 'info'
+ content: string
+ forCheckIn: boolean
+ printOrder: number
}
// ─── Guest ───────────────────────────────────────────────────────────────────