Files
hotelsync/src/pages/DocumentsPage.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

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