- 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>
491 lines
22 KiB
TypeScript
491 lines
22 KiB
TypeScript
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>
|
||
)
|
||
}
|