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:
364
src/pages/MigrationPage.tsx
Normal file
364
src/pages/MigrationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user