Files
hotelsync/src/pages/MigrationPage.tsx
HotelSync 0e0c11785f 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>
2026-03-11 19:16:55 +03:00

365 lines
16 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, 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>
)
}