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

View File

@@ -6,12 +6,18 @@ import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
const CHANNEL_ICONS: Record<string, string> = {
booking_com: '🔵',
airbnb: '🔴',
expedia: '🟡',
vrbo: '🟢',
booking_com: '🔵',
airbnb: '🔴',
expedia: '🟡',
vrbo: '🟢',
yandex_travel: '🔴',
ostrovok: '🟣',
sutochno: '🟠',
onetwotrip: '🔵',
}
const RUSSIAN_CHANNELS = new Set(['yandex_travel', 'ostrovok', 'sutochno', 'onetwotrip'])
function SyncStatusBadge({ status }: { status: SyncStatus }) {
const map: Record<SyncStatus, { icon: React.ElementType; label: string; cls: string }> = {
idle: { icon: Clock, label: 'Не настроен', cls: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' },
@@ -53,6 +59,9 @@ export function ChannelsPage() {
const totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0)
const activeCount = channels.filter(c => c.isEnabled).length
const internationalChannels = channels.filter(c => !RUSSIAN_CHANNELS.has(c.name))
const russianChannels = channels.filter(c => RUSSIAN_CHANNELS.has(c.name))
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
@@ -78,16 +87,38 @@ export function ChannelsPage() {
))}
</div>
{/* Channels */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{channels.map(channel => (
<ChannelCard
key={channel.id}
channel={channel}
onSync={() => triggerSync(channel.id)}
onToggle={() => toggleChannel(channel.id)}
/>
))}
{/* International Channels */}
<div>
<h2 className="text-base font-semibold text-slate-700 dark:text-slate-300 mb-3">
🌍 Международные площадки
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{internationalChannels.map(channel => (
<ChannelCard
key={channel.id}
channel={channel}
onSync={() => triggerSync(channel.id)}
onToggle={() => toggleChannel(channel.id)}
/>
))}
</div>
</div>
{/* Russian Channels */}
<div>
<h2 className="text-base font-semibold text-slate-700 dark:text-slate-300 mb-3">
🇷🇺 Российские площадки
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{russianChannels.map(channel => (
<ChannelCard
key={channel.id}
channel={channel}
onSync={() => triggerSync(channel.id)}
onToggle={() => toggleChannel(channel.id)}
/>
))}
</div>
</div>
{/* Info */}
@@ -167,7 +198,6 @@ function ChannelCard({ channel, onSync, onToggle }: {
{channel.mappings.slice(0, 2).map(m => (
<div key={m.localRoomId} className="flex items-center justify-between text-xs">
<span className="text-slate-600 dark:text-slate-400">{
// get room number from localRoomId
m.localRoomId.replace('r', '')
}</span>
<span className="text-slate-400"></span>

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>
)
}

View File

@@ -1,17 +1,21 @@
import { useState } from 'react'
import { BedDouble, Users, Wifi, Plus, Search } from 'lucide-react'
import { BedDouble, Users, Wifi, Plus, Search, Pencil } from 'lucide-react'
import { MOCK_ROOMS } from '../data/mockData'
import type { Room } from '../types'
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { RoomModal } from '../components/rooms/RoomModal'
export function RoomsPage() {
const [rooms, setRooms] = useState<Room[]>(MOCK_ROOMS)
const [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
const [modalOpen, setModalOpen] = useState(false)
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
const floors = [...new Set(MOCK_ROOMS.map(r => r.floor))].sort()
const floors = [...new Set(rooms.map(r => r.floor))].sort()
const filtered = MOCK_ROOMS.filter(r => {
const filtered = rooms.filter(r => {
const matchSearch = search === '' ||
r.number.includes(search) ||
r.type.toLowerCase().includes(search.toLowerCase()) ||
@@ -21,10 +25,33 @@ export function RoomsPage() {
})
const stats = {
available: MOCK_ROOMS.filter(r => r.status === 'available').length,
occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length,
maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance').length,
dirty: MOCK_ROOMS.filter(r => r.housekeepingStatus === 'dirty' || r.housekeepingStatus === 'cleaning').length,
available: rooms.filter(r => r.status === 'available').length,
occupied: rooms.filter(r => r.status === 'occupied').length,
maintenance: rooms.filter(r => r.status === 'maintenance').length,
dirty: rooms.filter(r => r.housekeepingStatus === 'dirty' || r.housekeepingStatus === 'cleaning').length,
}
const openCreate = () => {
setEditingRoom(undefined)
setModalOpen(true)
}
const openEdit = (room: Room) => {
setEditingRoom(room)
setModalOpen(true)
}
const handleSave = (room: Room) => {
setRooms(prev => {
const idx = prev.findIndex(r => r.id === room.id)
if (idx >= 0) {
const updated = [...prev]
updated[idx] = room
return updated
}
return [...prev, room]
})
setModalOpen(false)
}
return (
@@ -33,9 +60,9 @@ export function RoomsPage() {
<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">{MOCK_ROOMS.length} номеров</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{rooms.length} номеров</p>
</div>
<button className="btn-primary">
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />
<span className="hidden sm:inline">Добавить номер</span>
</button>
@@ -98,16 +125,32 @@ export function RoomsPage() {
{/* Room grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(room => (
<RoomCard key={room.id} room={room} />
<RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
))}
</div>
{/* Modal */}
<RoomModal
open={modalOpen}
room={editingRoom}
onClose={() => setModalOpen(false)}
onSave={handleSave}
/>
</div>
)
}
function RoomCard({ room }: { room: Room }) {
function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
return (
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group">
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative">
{/* Edit button on hover */}
<button
onClick={e => { e.stopPropagation(); onEdit() }}
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:text-brand-600"
>
<Pencil size={13} />
</button>
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2">
@@ -120,11 +163,11 @@ function RoomCard({ room }: { room: Room }) {
</div>
<span className="text-sm text-slate-500 dark:text-slate-400">{room.type} · Этаж {room.floor}</span>
</div>
<div className={cn('w-3 h-3 rounded-full mt-1', {
<div className={cn('w-3 h-3 rounded-full mt-1 mr-6', {
'bg-emerald-500': room.status === 'available',
'bg-brand-500': room.status === 'occupied',
'bg-orange-500': room.status === 'maintenance',
'bg-slate-400': room.status === 'blocked',
'bg-brand-500': room.status === 'occupied',
'bg-orange-500': room.status === 'maintenance',
'bg-slate-400': room.status === 'blocked',
})} />
</div>
@@ -158,7 +201,9 @@ function RoomCard({ room }: { room: Room }) {
<span className="text-base font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(room.baseRate)}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">/ночь</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
/ночь{room.allowHourly ? ` · ${formatCurrency(room.hourlyRate ?? 0)}` : ''}
</span>
</div>
{room.amenities.length > 0 && (