- {/* Payment method */}
-
-
Способ оплаты
-
- setPaymentMethod(p => p === 'cash' ? null : 'cash')}
- className={cn(
- 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
- paymentMethod === 'cash'
- ? 'bg-emerald-600 text-white border-emerald-600'
- : 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-emerald-400',
- )}
- >
-
- Наличные
-
- setPaymentMethod(p => p === 'terminal' ? null : 'terminal')}
- className={cn(
- 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
- paymentMethod === 'terminal'
- ? 'bg-blue-600 text-white border-blue-600'
- : 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-blue-400',
- )}
- >
-
- Терминал
-
-
-
+ )}
- {/* Amount paid */}
+ {/* Dates */}
+ {isHourly && room?.allowHourly ? (
+
-
- Оплачено (₽)
+
+ Дата *
setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
+ type="date"
+ className="input"
+ value={hourlyDate}
+ onChange={e => setHourlyDate(e.target.value)}
+ />
+
+
+
+
+ Начало
+
+ setStartHour(parseInt(e.target.value))}
+ >
+ {HOURS.map(h => (
+ {String(h).padStart(2, '0')}:00
+ ))}
+
+
+
+
+ Конец
+
+ setEndHour(parseInt(e.target.value))}
+ >
+ {HOURS.filter(h => h > startHour).map(h => (
+ {String(h).padStart(2, '0')}:00
+ ))}
+
+
+
+
+ ) : (
+
-
-
-
- {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
-
-
- {total.toLocaleString('ru-RU')} ₽
-
+ {/* ── RIGHT COLUMN ── */}
+
+ {/* Guest tags */}
+
+
+ Статус гостя
+
+
+ {GUEST_TAGS.map(tag => {
+ const isVip = tag === 'VIP'
+ const selected = guestTags.includes(tag)
+ return (
+ toggleTag(tag)}
+ className={cn(
+ 'flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
+ selected
+ ? isVip
+ ? 'bg-amber-500 border-amber-500 text-white'
+ : 'bg-brand-600 border-brand-600 text-white'
+ : 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
+ )}
+ >
+ {isVip && }
+ {tag}
+
+ )
+ })}
- {paidAmount > 0 && (
-
- Оплачено
-
- {paidAmount.toLocaleString('ru-RU')} ₽
-
-
- )}
- {debt > 0 && (
-
-
-
{isFutureBooking ? 'Задолженность' : 'Долг при заселении'}
-
{debt.toLocaleString('ru-RU')} ₽
-
- )}
- )}
+
+ {/* Source */}
+
+
+ Источник
+
+
+ {(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => (
+ set('source', k)}
+ className={cn(
+ 'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
+ form.source === k
+ ? 'bg-brand-600 text-white border-brand-600'
+ : 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
+ )}
+ >
+ {v}
+
+ ))}
+
+
+
+ {/* Payment */}
+ {total > 0 && (
+
+
+
+
+
Способ оплаты
+
+ setPaymentMethod(p => p === 'cash' ? null : 'cash')}
+ className={cn(
+ 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
+ paymentMethod === 'cash'
+ ? 'bg-emerald-600 text-white border-emerald-600'
+ : 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-emerald-400',
+ )}
+ >
+
+ Наличные
+
+ setPaymentMethod(p => p === 'terminal' ? null : 'terminal')}
+ className={cn(
+ 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
+ paymentMethod === 'terminal'
+ ? 'bg-blue-600 text-white border-blue-600'
+ : 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-blue-400',
+ )}
+ >
+
+ Терминал
+
+
+
+
+
+ Оплачено (₽)
+
+ setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
+ />
+
+
+
+ )}
+
+ {/* Notes */}
+
+
+ Примечания
+
+
+
+ {/* Summary */}
+ {total > 0 && room && (
+
+ {isHourly && room.allowHourly ? (
+
+
+ {hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽
+
+
+ {total.toLocaleString('ru-RU')} ₽
+
+
+ ) : (
+
+
+ {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
+
+
+ {total.toLocaleString('ru-RU')} ₽
+
+
+ )}
+ {paidAmount > 0 && (
+
+ Оплачено
+
+ {paidAmount.toLocaleString('ru-RU')} ₽
+
+
+ )}
+ {debt > 0 && (
+
+
+
{isFutureBooking ? 'Задолженность' : 'Долг при заселении'}
+
{debt.toLocaleString('ru-RU')} ₽
+
+ )}
+
+ )}
+
diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx
index a8e69ec..a90fb29 100644
--- a/src/components/calendar/BookingCalendar.tsx
+++ b/src/components/calendar/BookingCalendar.tsx
@@ -335,6 +335,8 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
if (!style) return null
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
const isFading = fadingBookingIds?.has(booking.id)
+ const isUnpaid = booking.paidAmount < booking.totalAmount
+ const guestCount = (booking.adults ?? 0) + (booking.children ?? 0)
return (
{ e.stopPropagation(); setSelectedBooking(booking) }}
- title={`${booking.guestName} • ${booking.checkIn} – ${booking.checkOut}`}
+ title={`${booking.guestName} • ${booking.checkIn} – ${booking.checkOut}${isUnpaid ? ' • Не оплачено' : ''}`}
>
+ {/* Unpaid indicator */}
+ {isUnpaid && (
+
+ )}
{booking.guestName}
- {style.width > 80 && (
-
- {nights}н • {SOURCE_LABELS[booking.source]}
+ {style.width > 90 && guestCount > 0 && (
+
+ {guestCount}г
+
+ )}
+ {style.width > 130 && (
+
+ {nights}н
)}
diff --git a/src/components/rooms/RoomModal.tsx b/src/components/rooms/RoomModal.tsx
new file mode 100644
index 0000000..b4fc140
--- /dev/null
+++ b/src/components/rooms/RoomModal.tsx
@@ -0,0 +1,326 @@
+import { useState } from 'react'
+import { Modal } from '../ui/Modal'
+import { cn } from '../../lib/utils'
+import type { Room, RoomStatus, HousekeepingStatus, BedType } from '../../types'
+
+const AMENITY_LIST = [
+ 'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
+ 'Panoramic view', 'Kitchen', 'Washing machine', 'Butler', 'Terrace',
+]
+
+const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
+
+const BED_TYPES: { value: BedType; label: string }[] = [
+ { value: 'single', label: 'Одна кровать' },
+ { value: 'double', label: 'Двуспальная' },
+ { value: 'queen', label: 'Queen' },
+ { value: 'king', label: 'King' },
+ { value: 'twin', label: 'Две кровати' },
+]
+
+const ROOM_STATUSES: { value: RoomStatus; label: string }[] = [
+ { value: 'available', label: 'Свободен' },
+ { value: 'occupied', label: 'Занят' },
+ { value: 'maintenance', label: 'Ремонт' },
+ { value: 'blocked', label: 'Закрыт' },
+]
+
+const HK_STATUSES: { value: HousekeepingStatus; label: string }[] = [
+ { value: 'clean', label: 'Чистый' },
+ { value: 'dirty', label: 'Грязный' },
+ { value: 'cleaning', label: 'Убирается' },
+ { value: 'inspect', label: 'Проверка' },
+]
+
+interface RoomModalProps {
+ open: boolean
+ room?: Room
+ onClose: () => void
+ onSave: (room: Room) => void
+}
+
+export function RoomModal({ open, room, onClose, onSave }: RoomModalProps) {
+ const isEdit = !!room
+
+ const [form, setForm] = useState({
+ number: room?.number ?? '',
+ name: room?.name ?? '',
+ floor: room?.floor ?? 1,
+ type: room?.type ?? 'Стандарт',
+ bedType: (room?.bedType ?? 'double') as BedType,
+ maxGuests: room?.maxGuests ?? 2,
+ baseRate: room?.baseRate ?? 5000,
+ status: (room?.status ?? 'available') as RoomStatus,
+ housekeepingStatus: (room?.housekeepingStatus ?? 'clean') as HousekeepingStatus,
+ sortOrder: room?.sortOrder ?? 99,
+ allowHourly: room?.allowHourly ?? false,
+ hourlyRate: room?.hourlyRate ?? 1000,
+ })
+
+ const [amenities, setAmenities] = useState
(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
+
+ const set = (k: K, v: typeof form[K]) =>
+ setForm(prev => ({ ...prev, [k]: v }))
+
+ const toggleAmenity = (a: string) =>
+ setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
+
+ const handleSave = () => {
+ if (!form.number) return
+ const saved: Room = {
+ id: room?.id ?? `r-${Date.now()}`,
+ hotelId: room?.hotelId ?? 'hotel-1',
+ number: form.number,
+ name: form.name || undefined,
+ floor: form.floor,
+ type: form.type,
+ bedType: form.bedType,
+ maxGuests: form.maxGuests,
+ baseRate: form.baseRate,
+ status: form.status,
+ housekeepingStatus: form.housekeepingStatus,
+ amenities,
+ sortOrder: form.sortOrder,
+ allowHourly: form.allowHourly || undefined,
+ hourlyRate: form.allowHourly ? form.hourlyRate : undefined,
+ }
+ onSave(saved)
+ }
+
+ return (
+
+ Отмена
+
+ {isEdit ? 'Сохранить' : 'Добавить номер'}
+
+ >
+ }
+ >
+
+ {/* Row 1: Number | Name */}
+
+
+ {/* Row 2: Floor | Type */}
+
+
+
+ Этаж
+
+ set('floor', parseInt(e.target.value) || 1)}
+ />
+
+
+
+ Тип номера
+
+ set('type', e.target.value)}
+ >
+ {ROOM_TYPES.map(t => (
+ {t}
+ ))}
+
+
+
+
+ {/* Row 3: Bed type | Max guests */}
+
+
+
+ Тип кровати
+
+ set('bedType', e.target.value as BedType)}
+ >
+ {BED_TYPES.map(b => (
+ {b.label}
+ ))}
+
+
+
+
+ Макс. гостей
+
+ set('maxGuests', parseInt(e.target.value) || 1)}
+ />
+
+
+
+ {/* Row 4: Base rate | Status */}
+
+
+
+ Цена ₽/ночь
+
+ set('baseRate', parseInt(e.target.value) || 0)}
+ />
+
+
+
+ Статус номера
+
+ set('status', e.target.value as RoomStatus)}
+ >
+ {ROOM_STATUSES.map(s => (
+ {s.label}
+ ))}
+
+
+
+
+ {/* Row 5: Housekeeping | Sort order */}
+
+
+
+ Статус уборки
+
+ set('housekeepingStatus', e.target.value as HousekeepingStatus)}
+ >
+ {HK_STATUSES.map(s => (
+ {s.label}
+ ))}
+
+
+
+
+ Порядок сортировки
+
+ set('sortOrder', parseInt(e.target.value) || 1)}
+ />
+
+
+
+ {/* Hourly section */}
+
+
+
Почасовая аренда
+
set('allowHourly', !form.allowHourly)}
+ className={cn(
+ 'relative w-11 h-6 rounded-full transition-colors',
+ form.allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
+ )}
+ >
+
+
+
+ {form.allowHourly && (
+
+
+ Цена ₽/час
+
+ set('hourlyRate', parseInt(e.target.value) || 0)}
+ />
+
+ )}
+ {!form.allowHourly && (
+
+
+ Разрешить бронирование номера на несколько часов
+
+
+ )}
+
+
+ {/* Amenities */}
+
+
+ Удобства
+
+
+ {AMENITY_LIST.map(a => {
+ const selected = amenities.includes(a)
+ return (
+ toggleAmenity(a)}
+ className={cn(
+ 'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
+ selected
+ ? 'bg-brand-600 text-white border-brand-600'
+ : 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
+ )}
+ >
+ {a}
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index aec08f0..6ca17e1 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -7,7 +7,7 @@ interface ModalProps {
onClose: () => void
title: string
children: React.ReactNode
- size?: 'sm' | 'md' | 'lg' | 'xl'
+ size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
footer?: React.ReactNode
}
@@ -16,6 +16,7 @@ const SIZES = {
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
+ '2xl': 'max-w-3xl',
}
export function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) {
diff --git a/src/contexts/ModulesContext.tsx b/src/contexts/ModulesContext.tsx
index 2f1a3b5..0879932 100644
--- a/src/contexts/ModulesContext.tsx
+++ b/src/contexts/ModulesContext.tsx
@@ -5,12 +5,17 @@ const DEFAULT_STATUSES: Record = {
'housekeeping': 'active',
'channel-manager': 'active',
'wifi-auth': 'active',
- 'payments': 'trial',
- 'tv-welcome': 'inactive',
- 'smart-locks': 'inactive',
+ 'payments': 'active',
+ 'tv-welcome': 'active',
+ 'smart-locks': 'active',
'olap-reports': 'active',
- 'website-builder': 'inactive',
- 'booking-widget': 'inactive',
+ 'website-builder': 'active',
+ 'booking-widget': 'active',
+ 'pos': 'active',
+ 'reviews': 'active',
+ 'room-service': 'active',
+ 'rental': 'active',
+ 'migration': 'active',
}
interface ModulesContextValue {
@@ -21,9 +26,18 @@ interface ModulesContextValue {
const ModulesContext = createContext(null)
+const MODULES_VERSION = '2'
+
export function ModulesProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = useState>(() => {
try {
+ // Reset stored statuses when version changes so new defaults apply
+ const storedVersion = localStorage.getItem('hotelsync-modules-ver')
+ if (storedVersion !== MODULES_VERSION) {
+ localStorage.removeItem('hotelsync-modules')
+ localStorage.setItem('hotelsync-modules-ver', MODULES_VERSION)
+ return DEFAULT_STATUSES
+ }
const stored = localStorage.getItem('hotelsync-modules')
return stored ? { ...DEFAULT_STATUSES, ...JSON.parse(stored) } : DEFAULT_STATUSES
} catch {
diff --git a/src/data/mockData.ts b/src/data/mockData.ts
index 21523e7..82ef0a1 100644
--- a/src/data/mockData.ts
+++ b/src/data/mockData.ts
@@ -176,6 +176,50 @@ export const MOCK_CHANNELS: Channel[] = [
bookingsImported: 0,
mappings: [],
},
+ {
+ id: 'ch5',
+ hotelId: 'hotel-1',
+ name: 'yandex_travel',
+ displayName: 'Яндекс Путешествия',
+ isEnabled: false,
+ lastSyncAt: null,
+ lastSyncStatus: 'idle',
+ bookingsImported: 0,
+ mappings: [],
+ },
+ {
+ id: 'ch6',
+ hotelId: 'hotel-1',
+ name: 'ostrovok',
+ displayName: 'Островок',
+ isEnabled: false,
+ lastSyncAt: null,
+ lastSyncStatus: 'idle',
+ bookingsImported: 0,
+ mappings: [],
+ },
+ {
+ id: 'ch7',
+ hotelId: 'hotel-1',
+ name: 'sutochno',
+ displayName: 'Суточно.ру',
+ isEnabled: false,
+ lastSyncAt: null,
+ lastSyncStatus: 'idle',
+ bookingsImported: 0,
+ mappings: [],
+ },
+ {
+ id: 'ch8',
+ hotelId: 'hotel-1',
+ name: 'onetwotrip',
+ displayName: 'OneTwoTrip',
+ isEnabled: false,
+ lastSyncAt: null,
+ lastSyncStatus: 'idle',
+ bookingsImported: 0,
+ mappings: [],
+ },
]
// ─── Housekeeping ──────────────────────────────────────────────────────────────
diff --git a/src/data/modulesData.ts b/src/data/modulesData.ts
index 141cafa..279b603 100644
--- a/src/data/modulesData.ts
+++ b/src/data/modulesData.ts
@@ -1,7 +1,7 @@
import {
Wifi, CreditCard, Tv2, KeyRound,
BarChart3, Globe, CalendarCheck2, Sparkles, Network,
- ShoppingCart, Star, UtensilsCrossed, CalendarClock,
+ ShoppingCart, Star, UtensilsCrossed, CalendarClock, ArrowLeftRight,
} from 'lucide-react'
import type { ElementType } from 'react'
@@ -367,4 +367,29 @@ export const MODULES_DATA: ModuleDef[] = [
icon: CalendarClock,
},
},
+ {
+ id: 'migration',
+ name: 'Миграция данных',
+ tagline: 'Перенос из другой PMS за 15 минут',
+ description:
+ 'Перенос броней, номеров и гостей из другой PMS системы. Поддержка Bnovo, Hotelbird, Fidelio, 1С Отель. Импорт через CSV или Excel — без потери данных.',
+ icon: ArrowLeftRight,
+ iconBg: 'bg-violet-100 dark:bg-violet-900/40',
+ iconColor: 'text-violet-600 dark:text-violet-400',
+ accentColor: 'bg-violet-500',
+ price: 0,
+ badge: 'Бесплатно',
+ features: [
+ 'Импорт из Bnovo, Hotelbird, 1С Отель',
+ 'CSV / Excel загрузка',
+ 'Маппинг полей',
+ 'Импорт гостевой базы',
+ 'Перенос броней',
+ ],
+ sidebarItem: {
+ path: '/migration',
+ label: 'Миграция',
+ icon: ArrowLeftRight,
+ },
+ },
]
diff --git a/src/pages/ChannelsPage.tsx b/src/pages/ChannelsPage.tsx
index c177093..2f86950 100644
--- a/src/pages/ChannelsPage.tsx
+++ b/src/pages/ChannelsPage.tsx
@@ -6,12 +6,18 @@ import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
const CHANNEL_ICONS: Record = {
- 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 = {
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 (
{/* Header */}
@@ -78,16 +87,38 @@ export function ChannelsPage() {
))}
- {/* Channels */}
-
- {channels.map(channel => (
-
triggerSync(channel.id)}
- onToggle={() => toggleChannel(channel.id)}
- />
- ))}
+ {/* International Channels */}
+
+
+ 🌍 Международные площадки
+
+
+ {internationalChannels.map(channel => (
+ triggerSync(channel.id)}
+ onToggle={() => toggleChannel(channel.id)}
+ />
+ ))}
+
+
+
+ {/* Russian Channels */}
+
+
+ 🇷🇺 Российские площадки
+
+
+ {russianChannels.map(channel => (
+ triggerSync(channel.id)}
+ onToggle={() => toggleChannel(channel.id)}
+ />
+ ))}
+
{/* Info */}
@@ -167,7 +198,6 @@ function ChannelCard({ channel, onSync, onToggle }: {
{channel.mappings.slice(0, 2).map(m => (
№{
- // get room number from localRoomId
m.localRoomId.replace('r', '')
}
→
diff --git a/src/pages/MigrationPage.tsx b/src/pages/MigrationPage.tsx
new file mode 100644
index 0000000..f2fdc17
--- /dev/null
+++ b/src/pages/MigrationPage.tsx
@@ -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
(1)
+ const [selectedSource, setSelectedSource] = useState(null)
+ const [fileName, setFileName] = useState(null)
+ const [isDragging, setIsDragging] = useState(false)
+ const [importing, setImporting] = useState(false)
+ const [progress, setProgress] = useState(0)
+ const [done, setDone] = useState(false)
+ const fileInputRef = useRef(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) => {
+ 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 (
+
+ {/* Header */}
+
+
+
+
Миграция данных
+
Перенос данных из другой PMS системы
+
+
+
+ {/* Step indicator */}
+
+ {([1, 2, 3] as Step[]).map((s, i) => (
+ <>
+
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 ? : s}
+
+ {i < 2 && (
+
s ? 'bg-violet-400' : 'bg-slate-200 dark:bg-slate-700')} />
+ )}
+ >
+ ))}
+
+
+ {/* Step labels */}
+
+ Выбор источника
+ Загрузка файла
+ Импорт
+
+
+ {/* ── STEP 1: Source selection ── */}
+ {step === 1 && (
+
+
+ Выберите систему, из которой переносите данные
+
+
+ {SOURCES.map(src => (
+
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',
+ )}
+ >
+
+ {src.icon}
+ {src.name}
+
+ {src.description}
+
+ ))}
+
+
+
setStep(2)}
+ >
+ Далее
+
+
+
+
+ )}
+
+ {/* ── STEP 2: File upload ── */}
+ {step === 2 && (
+
+
+ Загрузите файл экспорта
+
+
+
+ Выгрузите данные из {SOURCES.find(s => s.id === selectedSource)?.name} в формате CSV или Excel (.xlsx).
+ Файл должен содержать заголовки колонок. Максимальный размер файла: 50 МБ.
+
+
+ {/* Dropzone */}
+
{ 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',
+ )}
+ >
+
+ {fileName ? (
+
+
+
{fileName}
+
Нажмите для замены файла
+
+ ) : (
+
+
+
+ Перетащите файл сюда или нажмите для выбора
+
+
CSV, XLSX, XLS — до 50 МБ
+
+ )}
+
+
+ {/* Download template */}
+
+
+
Нет подходящего формата?
+
Скачайте шаблон и заполните вручную
+
+
+
+ Шаблон
+
+
+
+ {/* Sample table */}
+
+
+ Ожидаемые колонки файла:
+
+
+
+
+
+ {SAMPLE_COLUMNS.map(col => (
+
+ {col.label}
+
+ ))}
+
+
+
+
+ {SAMPLE_COLUMNS.map(col => (
+
+ {col.example}
+
+ ))}
+
+
+ {SAMPLE_COLUMNS.map(col => (
+
+ ...
+
+ ))}
+
+
+
+
+
+
+
+
setStep(1)}>
+
+ Назад
+
+
setStep(3)}
+ >
+ Далее
+
+
+
+
+ )}
+
+ {/* ── STEP 3: Import ── */}
+ {step === 3 && (
+
+
+ Импорт данных
+
+
+ {!importing && !done && (
+
+
+
{SOURCES.find(s => s.id === selectedSource)?.icon}
+
+
+ {SOURCES.find(s => s.id === selectedSource)?.name}
+
+
{fileName}
+
+
+
+ Система проверит файл, смаппирует поля и импортирует данные. Это займёт несколько секунд.
+
+
+
setStep(2)}>
+
+ Назад
+
+
+
+ Начать импорт
+
+
+
+ )}
+
+ {importing && !done && (
+
+
+
+
Импорт данных...
+
Обработка файла и перенос данных
+
+
+
+ {Math.round(Math.min(100, progress))}%
+
+
+ )}
+
+ {done && (
+
+
+
+
+
+
Импорт завершён!
+
Данные успешно перенесены в HotelSync
+
+
+
+ {[
+ { 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 => (
+
+
{stat.value}
+
{stat.label}
+
+ ))}
+
+
+
+ Готово
+
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx
index 054503d..d39e849 100644
--- a/src/pages/RoomsPage.tsx
+++ b/src/pages/RoomsPage.tsx
@@ -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
(MOCK_ROOMS)
const [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState('all')
+ const [modalOpen, setModalOpen] = useState(false)
+ const [editingRoom, setEditingRoom] = useState(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() {
Номерной фонд
-
{MOCK_ROOMS.length} номеров
+
{rooms.length} номеров
-
+
Добавить номер
@@ -98,16 +125,32 @@ export function RoomsPage() {
{/* Room grid */}
{filtered.map(room => (
-
+ openEdit(room)} />
))}
+
+ {/* Modal */}
+ setModalOpen(false)}
+ onSave={handleSave}
+ />
)
}
-function RoomCard({ room }: { room: Room }) {
+function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
return (
-
+
+ {/* Edit button on hover */}
+
{ 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"
+ >
+
+
+
@@ -120,11 +163,11 @@ function RoomCard({ room }: { room: Room }) {
{room.type} · Этаж {room.floor}
-
@@ -158,7 +201,9 @@ function RoomCard({ room }: { room: Room }) {
{formatCurrency(room.baseRate)}
-
/ночь
+
+ /ночь{room.allowHourly ? ` · ${formatCurrency(room.hourlyRate ?? 0)}/ч` : ''}
+
{room.amenities.length > 0 && (
diff --git a/src/types/index.ts b/src/types/index.ts
index 307d647..013f9c4 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -56,6 +56,8 @@ export interface Room {
housekeepingStatus: HousekeepingStatus
amenities: string[]
sortOrder: number
+ allowHourly?: boolean
+ hourlyRate?: number
}
// ─── Guest ───────────────────────────────────────────────────────────────────
@@ -115,7 +117,7 @@ export interface DraftBooking {
// ─── Channel ─────────────────────────────────────────────────────────────────
-export type ChannelName = 'booking_com' | 'airbnb' | 'expedia' | 'vrbo'
+export type ChannelName = 'booking_com' | 'airbnb' | 'expedia' | 'vrbo' | 'yandex_travel' | 'ostrovok' | 'sutochno' | 'onetwotrip'
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'
export interface ChannelMapping {