Wide BookingModal (2-col), RoomModal, RU channels, migration module, all modules active

- BookingModal: 2-column layout (size=2xl/max-w-3xl), no scroll needed; hourly mode toggle for rooms with allowHourly=true
- RoomModal: full add/edit form with hourly rate toggle and amenities checkboxes; RoomsPage wired up
- Channel manager: Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip added; split into RU/International sections
- Migration module: 3-step wizard (source select → file upload → progress/results)
- All modules set to active by default (version bump to reset localStorage)
- BookingCalendar booking blocks: guest count badge (Xг), unpaid red dot indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 19:16:55 +03:00
parent fbf4b269e3
commit 0e0c11785f
12 changed files with 1283 additions and 298 deletions

364
src/pages/MigrationPage.tsx Normal file
View File

@@ -0,0 +1,364 @@
import { useState, useEffect, useRef } from 'react'
import { ArrowLeftRight, Upload, Download, CheckCircle2, ArrowRight, ArrowLeft, FileSpreadsheet } from 'lucide-react'
import { cn } from '../lib/utils'
const SOURCES = [
{ id: 'bnovo', icon: '🏨', name: 'Bnovo', description: 'Российская PMS Bnovo. Экспорт из раздела "Отчёты"' },
{ id: 'hotelbird', icon: '🐦', name: 'Hotelbird', description: 'HotelsPro / Hotelbird. CSV из личного кабинета' },
{ id: 'fidelio', icon: '🏢', name: 'Fidelio Opera', description: 'Oracle Hospitality / Fidelio. XML / CSV экспорт' },
{ id: '1c', icon: '📊', name: '1С Отель', description: 'Выгрузка из 1С:Отель в формате Excel' },
{ id: 'rms', icon: '☁️', name: 'RMS Cloud', description: 'RMS Cloud PMS. Экспорт через API или CSV' },
{ id: 'csv', icon: '📄', name: 'Другая (CSV)', description: 'Любая другая система. Загрузите CSV или Excel по шаблону' },
]
const SAMPLE_COLUMNS = [
{ key: 'room', label: 'Номер комнаты', example: '101' },
{ key: 'guest', label: 'Имя гостя', example: 'Иванов Иван' },
{ key: 'checkin', label: 'Дата заезда', example: '2026-03-15' },
{ key: 'checkout', label: 'Дата выезда', example: '2026-03-18' },
{ key: 'amount', label: 'Сумма', example: '13500' },
{ key: 'status', label: 'Статус', example: 'confirmed' },
]
type Step = 1 | 2 | 3
export function MigrationPage() {
const [step, setStep] = useState<Step>(1)
const [selectedSource, setSelectedSource] = useState<string | null>(null)
const [fileName, setFileName] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [importing, setImporting] = useState(false)
const [progress, setProgress] = useState(0)
const [done, setDone] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) setFileName(file.name)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) setFileName(file.name)
}
const startImport = () => {
setImporting(true)
setProgress(0)
setDone(false)
}
useEffect(() => {
if (!importing || done) return
const interval = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
clearInterval(interval)
setDone(true)
return 100
}
return prev + Math.random() * 15
})
}, 150)
return () => clearInterval(interval)
}, [importing, done])
const reset = () => {
setStep(1)
setSelectedSource(null)
setFileName(null)
setImporting(false)
setProgress(0)
setDone(false)
}
return (
<div className="p-4 md:p-6 max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/40 flex items-center justify-center">
<ArrowLeftRight size={20} className="text-violet-600 dark:text-violet-400" />
</div>
<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">Перенос данных из другой PMS системы</p>
</div>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2">
{([1, 2, 3] as Step[]).map((s, i) => (
<>
<div
key={s}
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors',
step === s
? 'bg-violet-600 text-white'
: step > s
? 'bg-violet-200 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300'
: 'bg-slate-100 dark:bg-slate-700 text-slate-400',
)}
>
{step > s ? <CheckCircle2 size={16} /> : s}
</div>
{i < 2 && (
<div className={cn('flex-1 h-0.5 transition-colors', step > s ? 'bg-violet-400' : 'bg-slate-200 dark:bg-slate-700')} />
)}
</>
))}
</div>
{/* Step labels */}
<div className="flex justify-between text-xs text-slate-500 dark:text-slate-400 -mt-4">
<span>Выбор источника</span>
<span>Загрузка файла</span>
<span>Импорт</span>
</div>
{/* ── STEP 1: Source selection ── */}
{step === 1 && (
<div className="space-y-4">
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-200">
Выберите систему, из которой переносите данные
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{SOURCES.map(src => (
<button
key={src.id}
type="button"
onClick={() => setSelectedSource(src.id)}
className={cn(
'text-left p-4 rounded-xl border-2 transition-all',
selectedSource === src.id
? 'border-violet-500 bg-violet-50 dark:bg-violet-900/20'
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:border-violet-300',
)}
>
<div className="flex items-center gap-3 mb-1.5">
<span className="text-2xl">{src.icon}</span>
<span className="font-semibold text-slate-900 dark:text-slate-100">{src.name}</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">{src.description}</p>
</button>
))}
</div>
<div className="flex justify-end">
<button
className="btn-primary gap-2"
disabled={!selectedSource}
onClick={() => setStep(2)}
>
Далее
<ArrowRight size={15} />
</button>
</div>
</div>
)}
{/* ── STEP 2: File upload ── */}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-200">
Загрузите файл экспорта
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Выгрузите данные из <strong>{SOURCES.find(s => s.id === selectedSource)?.name}</strong> в формате CSV или Excel (.xlsx).
Файл должен содержать заголовки колонок. Максимальный размер файла: 50 МБ.
</p>
{/* Dropzone */}
<div
onDragOver={e => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors',
isDragging
? 'border-violet-400 bg-violet-50 dark:bg-violet-900/20'
: fileName
? 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/10'
: 'border-slate-300 dark:border-slate-600 hover:border-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/10',
)}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleFileChange}
/>
{fileName ? (
<div className="flex flex-col items-center gap-2">
<FileSpreadsheet size={32} className="text-emerald-500" />
<p className="font-medium text-emerald-700 dark:text-emerald-400">{fileName}</p>
<p className="text-xs text-slate-500">Нажмите для замены файла</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload size={32} className="text-slate-400" />
<p className="font-medium text-slate-700 dark:text-slate-300">
Перетащите файл сюда или нажмите для выбора
</p>
<p className="text-xs text-slate-500">CSV, XLSX, XLS до 50 МБ</p>
</div>
)}
</div>
{/* Download template */}
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Нет подходящего формата?</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Скачайте шаблон и заполните вручную</p>
</div>
<button className="btn-secondary gap-1.5 text-sm">
<Download size={14} />
Шаблон
</button>
</div>
{/* Sample table */}
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Ожидаемые колонки файла:
</p>
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50 dark:bg-slate-700/40">
{SAMPLE_COLUMNS.map(col => (
<th key={col.key} className="text-left px-3 py-2 text-xs font-semibold text-slate-600 dark:text-slate-300 border-b border-slate-200 dark:border-slate-700">
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
{SAMPLE_COLUMNS.map(col => (
<td key={col.key} className="px-3 py-2 text-slate-500 dark:text-slate-400 text-xs font-mono">
{col.example}
</td>
))}
</tr>
<tr className="bg-slate-50/50 dark:bg-slate-700/20">
{SAMPLE_COLUMNS.map(col => (
<td key={col.key} className="px-3 py-2 text-slate-400 dark:text-slate-500 text-xs font-mono">
...
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
<div className="flex items-center justify-between">
<button className="btn-secondary gap-2" onClick={() => setStep(1)}>
<ArrowLeft size={15} />
Назад
</button>
<button
className="btn-primary gap-2"
disabled={!fileName}
onClick={() => setStep(3)}
>
Далее
<ArrowRight size={15} />
</button>
</div>
</div>
)}
{/* ── STEP 3: Import ── */}
{step === 3 && (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-200">
Импорт данных
</h2>
{!importing && !done && (
<div className="card p-5 space-y-3">
<div className="flex items-center gap-3">
<span className="text-2xl">{SOURCES.find(s => s.id === selectedSource)?.icon}</span>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">
{SOURCES.find(s => s.id === selectedSource)?.name}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
Система проверит файл, смаппирует поля и импортирует данные. Это займёт несколько секунд.
</p>
<div className="flex items-center justify-between pt-2">
<button className="btn-secondary gap-2" onClick={() => setStep(2)}>
<ArrowLeft size={15} />
Назад
</button>
<button className="btn-primary gap-2" onClick={startImport}>
<ArrowLeftRight size={15} />
Начать импорт
</button>
</div>
</div>
)}
{importing && !done && (
<div className="card p-6 space-y-4 text-center">
<div className="w-12 h-12 rounded-full bg-violet-100 dark:bg-violet-900/40 flex items-center justify-center mx-auto">
<ArrowLeftRight size={22} className="text-violet-600 dark:text-violet-400 animate-pulse" />
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 mb-1">Импорт данных...</p>
<p className="text-sm text-slate-500 dark:text-slate-400">Обработка файла и перенос данных</p>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-3 overflow-hidden">
<div
className="bg-violet-500 h-3 rounded-full transition-all duration-200"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{Math.round(Math.min(100, progress))}%
</p>
</div>
)}
{done && (
<div className="card p-6 space-y-5 text-center">
<div className="w-14 h-14 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center mx-auto">
<CheckCircle2 size={28} className="text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-lg font-bold text-slate-900 dark:text-slate-100 mb-1">Импорт завершён!</p>
<p className="text-sm text-slate-500 dark:text-slate-400">Данные успешно перенесены в HotelSync</p>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'Номеров импортировано', value: '12', color: 'text-brand-600 dark:text-brand-400' },
{ label: 'Броней импортировано', value: '47', color: 'text-violet-600 dark:text-violet-400' },
{ label: 'Гостей добавлено', value: '38', color: 'text-emerald-600 dark:text-emerald-400' },
].map(stat => (
<div key={stat.label} className="rounded-xl bg-slate-50 dark:bg-slate-700/40 p-3">
<p className={cn('text-2xl font-bold', stat.color)}>{stat.value}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{stat.label}</p>
</div>
))}
</div>
<button className="btn-primary w-full justify-center" onClick={reset}>
Готово
</button>
</div>
)}
</div>
)}
</div>
)
}