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>
This commit is contained in:
2026-03-11 22:17:54 +03:00
parent 56df6711e4
commit 96dfd755ed
11 changed files with 1816 additions and 529 deletions

490
src/pages/DocumentsPage.tsx Normal file
View File

@@ -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<DocumentTemplate['type']>(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<string | null>(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 (
<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-4xl bg-white dark:bg-slate-800 rounded-2xl shadow-2xl flex flex-col" style={{ maxHeight: '94vh' }}>
{/* 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 ? `Редактировать: ${template.name}` : 'Новый шаблон документа'}
</h2>
<div className="flex items-center gap-2">
<button
onClick={() => 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',
)}
>
<Eye size={14} />{preview ? 'Редактор' : 'Предпросмотр'}
</button>
<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>
</div>
<div className="flex-1 overflow-hidden flex">
{/* Left: editor */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Meta */}
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Название</label>
<input type="text" className="input" placeholder="Регистрационная карта" value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Тип документа</label>
<select className="input" value={type} onChange={e => setType(e.target.value as DocumentTemplate['type'])}>
{TEMPLATE_TYPES.map(t => <option key={t.value} value={t.value}>{t.icon} {t.label}</option>)}
</select>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer">
<input type="checkbox" checked={forCheckIn} onChange={e => setForCheckIn(e.target.checked)} className="rounded" />
Печатать при заселении (добавить в пакет документов)
</label>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden px-6 py-3">
{preview ? (
<div className="h-full overflow-y-auto">
<div className="bg-white border border-slate-200 rounded-xl p-6 font-mono text-sm text-slate-800 whitespace-pre-wrap leading-relaxed shadow-inner min-h-full">
{previewContent(content)}
</div>
</div>
) : (
<textarea
id="tpl-content"
className="w-full h-full border border-slate-200 dark:border-slate-600 rounded-xl p-4 font-mono text-sm bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-200 resize-none focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400"
placeholder="Введите содержимое документа. Используйте переменные из панели справа..."
value={content}
onChange={e => setContent(e.target.value)}
/>
)}
</div>
</div>
{/* Right: variables panel */}
<div className="w-56 border-l border-slate-200 dark:border-slate-700 flex flex-col shrink-0">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 shrink-0">
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Переменные</p>
<p className="text-[10px] text-slate-400 mt-0.5">Нажмите для вставки</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{VARIABLES.map(v => (
<button
key={v.var}
onClick={() => {
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"
>
<p className="text-xs font-mono text-brand-600 dark:text-brand-400 flex items-center gap-1">
{copiedVar === v.var ? <CheckCheck size={10} className="text-emerald-500" /> : <Copy size={10} className="opacity-0 group-hover:opacity-100" />}
{v.var}
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-400 mt-0.5">{v.desc}</p>
</button>
))}
</div>
</div>
</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>
)
}
// ── 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<string[]>(
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 (
<div className="relative">
<button
onClick={() => 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"
>
<Printer size={14} />
Печать документов
<ChevronDown size={13} className={cn('transition-transform', open && 'rotate-180')} />
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-72 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">Выберите документы для печати</p>
</div>
<div className="p-2 space-y-1">
{templates.map(t => {
const tplType = TEMPLATE_TYPES.find(x => x.value === t.type)
return (
<label key={t.id} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 cursor-pointer">
<input
type="checkbox"
checked={selected.includes(t.id)}
onChange={() => toggle(t.id)}
className="rounded"
/>
<span className="text-lg">{tplType?.icon ?? '📄'}</span>
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{t.name}</p>
{t.forCheckIn && (
<p className="text-[10px] text-brand-600 dark:text-brand-400">При заселении</p>
)}
</div>
</label>
)
})}
</div>
<div className="p-3 border-t border-slate-200 dark:border-slate-700 flex gap-2">
<button
onClick={() => { onPrint(selected); setOpen(false) }}
disabled={selected.length === 0}
className="btn-primary flex-1 justify-center disabled:opacity-40"
>
<Printer size={14} />
Печать ({selected.length})
</button>
<button
onClick={() => { onPrint(templates.map(t => t.id)); setOpen(false) }}
className="btn-secondary text-xs"
>
Все
</button>
</div>
</div>
</>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function DocumentsPage() {
const [templates, setTemplates] = useState<DocumentTemplate[]>(MOCK_TEMPLATES)
const [editingTpl, setEditingTpl] = useState<DocumentTemplate | undefined>()
const [editorOpen, setEditorOpen] = useState(false)
const [printedId, setPrintedId] = useState<string | null>(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 (
<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>
<div className="flex items-center gap-2">
<PrintPackageDropdown templates={templates} onPrint={handlePrint} />
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />Новый шаблон
</button>
</div>
</div>
{/* Check-in package info */}
{checkInTemplates.length > 0 && (
<div className="card p-4 border-emerald-200 dark:border-emerald-700/50 bg-emerald-50/50 dark:bg-emerald-900/10">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<Printer size={16} className="text-emerald-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">
{checkInTemplates.map(t => t.name).join(' · ')}
</p>
</div>
</div>
<button
onClick={() => 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"
>
<Printer size={13} />
Напечатать все
</button>
</div>
</div>
)}
{/* Templates list */}
<div className="space-y-3">
{templates.map(tpl => {
const tplType = TEMPLATE_TYPES.find(t => t.value === tpl.type)
return (
<div key={tpl.id} className="card p-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-2xl shrink-0">
{tplType?.icon ?? '📄'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{tpl.name}</h3>
{tpl.forCheckIn && (
<span className="text-[10px] bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 px-1.5 py-0.5 rounded-full font-medium">
При заселении
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">{tplType?.label}</p>
<p className="text-xs text-slate-400 mt-1 truncate font-mono">{tpl.content.slice(0, 80)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => { 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 ? <CheckCheck size={14} className="text-emerald-600" /> : <Printer size={14} />}
</button>
<button
onClick={() => 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"
>
<Pencil size={14} />
</button>
<button
onClick={() => 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"
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
)
})}
</div>
{editorOpen && (
<TemplateEditor
template={editingTpl}
onClose={() => setEditorOpen(false)}
onSave={handleSave}
/>
)}
</div>
)
}