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:
@@ -18,6 +18,7 @@ import { BookingWidgetPage } from './pages/BookingWidgetPage'
|
||||
import { AvailabilityPage } from './pages/AvailabilityPage'
|
||||
import { FloorMapPage } from './pages/FloorMapPage'
|
||||
import { AdminDashboard } from './pages/AdminDashboard'
|
||||
import { MigrationPage } from './pages/MigrationPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -45,6 +46,7 @@ export default function App() {
|
||||
<Route path="/booking-widget" element={<BookingWidgetPage />} />
|
||||
<Route path="/availability" element={<AvailabilityPage />} />
|
||||
<Route path="/floor-map" element={<FloorMapPage />} />
|
||||
<Route path="/migration" element={<MigrationPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin routes */}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { FloorMapModal } from '../floormap/FloorMapModal'
|
||||
|
||||
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
interface BookingModalProps {
|
||||
open: boolean
|
||||
draft: DraftBooking
|
||||
@@ -35,11 +37,22 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0)
|
||||
const [showFloorMap, setShowFloorMap] = useState(false)
|
||||
|
||||
// Hourly booking state
|
||||
const [isHourly, setIsHourly] = useState(false)
|
||||
const [hourlyDate, setHourlyDate] = useState(draft.checkIn)
|
||||
const [startHour, setStartHour] = useState(10)
|
||||
const [endHour, setEndHour] = useState(12)
|
||||
|
||||
const room = rooms.find(r => r.id === form.roomId)
|
||||
const nights = form.checkIn && form.checkOut
|
||||
? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000)
|
||||
: 0
|
||||
const total = (room?.baseRate ?? 0) * nights
|
||||
|
||||
const hourlyHours = Math.max(0, endHour - startHour)
|
||||
const total = isHourly && room?.allowHourly
|
||||
? (room.hourlyRate ?? 0) * hourlyHours
|
||||
: (room?.baseRate ?? 0) * nights
|
||||
|
||||
const debt = Math.max(0, total - paidAmount)
|
||||
const isFutureBooking = form.checkIn > format(new Date(), 'yyyy-MM-dd')
|
||||
|
||||
@@ -50,9 +63,25 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.guestName || !form.checkIn || !form.checkOut) return
|
||||
if (!form.guestName) return
|
||||
if (isHourly && room?.allowHourly) {
|
||||
if (!hourlyDate) return
|
||||
} else {
|
||||
if (!form.checkIn || !form.checkOut) return
|
||||
}
|
||||
|
||||
const hourlyPrefix = isHourly && room?.allowHourly
|
||||
? `[Почасово: ${String(startHour).padStart(2, '0')}:00–${String(endHour).padStart(2, '0')}:00] `
|
||||
: ''
|
||||
|
||||
const checkIn = isHourly && room?.allowHourly ? hourlyDate : form.checkIn
|
||||
const checkOut = isHourly && room?.allowHourly ? hourlyDate : form.checkOut
|
||||
|
||||
onSave({
|
||||
...form,
|
||||
checkIn,
|
||||
checkOut,
|
||||
notes: hourlyPrefix + form.notes,
|
||||
totalAmount: total,
|
||||
paidAmount,
|
||||
id: existing?.id ?? `b-${Date.now()}`,
|
||||
@@ -62,13 +91,15 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
})
|
||||
}
|
||||
|
||||
const showHourlyTab = room?.allowHourly === true
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
|
||||
size="lg"
|
||||
size="2xl"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
@@ -78,7 +109,9 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-6">
|
||||
{/* ── LEFT COLUMN ── */}
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
{/* Room */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -101,13 +134,13 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
>
|
||||
{rooms.map(r => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.number} — {r.type} ({r.baseRate.toLocaleString('ru-RU')} ₽/ночь)
|
||||
{r.number} — {r.type} ({r.baseRate.toLocaleString('ru-RU')} ₽/ночь{r.allowHourly ? `, ${(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч` : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Guest */}
|
||||
{/* Guest name + email */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
@@ -135,38 +168,82 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус гостя
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GUEST_TAGS.map(tag => {
|
||||
const isVip = tag === 'VIP'
|
||||
const selected = guestTags.includes(tag)
|
||||
return (
|
||||
{/* Hourly / Daily toggle */}
|
||||
{showHourlyTab && (
|
||||
<div className="flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600">
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
onClick={() => setIsHourly(false)}
|
||||
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',
|
||||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||||
!isHourly
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
{isVip && <Star size={10} className={selected ? 'text-white' : 'text-amber-500'} />}
|
||||
{tag}
|
||||
Посуточно
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsHourly(true)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||||
isHourly
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
Почасово
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
{isHourly && room?.allowHourly ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Дата *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={hourlyDate}
|
||||
onChange={e => setHourlyDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Начало
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={startHour}
|
||||
onChange={e => setStartHour(parseInt(e.target.value))}
|
||||
>
|
||||
{HOURS.map(h => (
|
||||
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Конец
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={endHour}
|
||||
onChange={e => setEndHour(parseInt(e.target.value))}
|
||||
>
|
||||
{HOURS.filter(h => h > startHour).map(h => (
|
||||
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
@@ -191,8 +268,9 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guests count */}
|
||||
{/* Adults + Children + Status */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
@@ -231,6 +309,40 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT COLUMN ── */}
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
{/* Guest tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус гостя
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GUEST_TAGS.map(tag => {
|
||||
const isVip = tag === 'VIP'
|
||||
const selected = guestTags.includes(tag)
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => 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 && <Star size={10} className={selected ? 'text-white' : 'text-amber-500'} />}
|
||||
{tag}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
@@ -257,13 +369,12 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</div>
|
||||
|
||||
{/* Payment */}
|
||||
{nights > 0 && total > 0 && (
|
||||
{total > 0 && (
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Оплата</p>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Payment method */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Способ оплаты</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -295,8 +406,6 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount paid */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">
|
||||
Оплачено (₽)
|
||||
@@ -329,8 +438,18 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{nights > 0 && room && (
|
||||
{total > 0 && room && (
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5">
|
||||
{isHourly && room.allowHourly ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{total.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
@@ -339,6 +458,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
{total.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{paidAmount > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">Оплачено</span>
|
||||
@@ -357,6 +477,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{showFloorMap && (
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={booking.id}
|
||||
@@ -351,14 +353,23 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
position: 'absolute',
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }}
|
||||
title={`${booking.guestName} • ${booking.checkIn} – ${booking.checkOut}`}
|
||||
title={`${booking.guestName} • ${booking.checkIn} – ${booking.checkOut}${isUnpaid ? ' • Не оплачено' : ''}`}
|
||||
>
|
||||
{/* Unpaid indicator */}
|
||||
{isUnpaid && (
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-500 shadow-sm mr-1 mt-0.5" />
|
||||
)}
|
||||
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
|
||||
{booking.guestName}
|
||||
</span>
|
||||
{style.width > 80 && (
|
||||
<span className="ml-2 opacity-75 text-[10px] shrink-0">
|
||||
{nights}н • {SOURCE_LABELS[booking.source]}
|
||||
{style.width > 90 && guestCount > 0 && (
|
||||
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
|
||||
{guestCount}г
|
||||
</span>
|
||||
)}
|
||||
{style.width > 130 && (
|
||||
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
|
||||
{nights}н
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
326
src/components/rooms/RoomModal.tsx
Normal file
326
src/components/rooms/RoomModal.tsx
Normal file
@@ -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<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
|
||||
|
||||
const set = <K extends keyof typeof form>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? `Редактировать номер ${room.number}` : 'Добавить номер'}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={handleSave} className="btn-primary" disabled={!form.number}>
|
||||
{isEdit ? 'Сохранить' : 'Добавить номер'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Number | Name */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Номер *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="101"
|
||||
value={form.number}
|
||||
onChange={e => set('number', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Пентхаус"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Floor | Type */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Этаж
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
className="input"
|
||||
value={form.floor}
|
||||
onChange={e => set('floor', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Тип номера
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.type}
|
||||
onChange={e => set('type', e.target.value)}
|
||||
>
|
||||
{ROOM_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Bed type | Max guests */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Тип кровати
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.bedType}
|
||||
onChange={e => set('bedType', e.target.value as BedType)}
|
||||
>
|
||||
{BED_TYPES.map(b => (
|
||||
<option key={b.value} value={b.value}>{b.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Макс. гостей
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="input"
|
||||
value={form.maxGuests}
|
||||
onChange={e => set('maxGuests', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Base rate | Status */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Цена ₽/ночь
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input"
|
||||
value={form.baseRate}
|
||||
onChange={e => set('baseRate', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус номера
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.status}
|
||||
onChange={e => set('status', e.target.value as RoomStatus)}
|
||||
>
|
||||
{ROOM_STATUSES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Housekeeping | Sort order */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус уборки
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.housekeepingStatus}
|
||||
onChange={e => set('housekeepingStatus', e.target.value as HousekeepingStatus)}
|
||||
>
|
||||
{HK_STATUSES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Порядок сортировки
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input"
|
||||
value={form.sortOrder}
|
||||
onChange={e => set('sortOrder', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly section */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Почасовая аренда</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
form.allowHourly ? 'left-[22px]' : 'left-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
{form.allowHourly && (
|
||||
<div className="p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Цена ₽/час
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input w-40"
|
||||
value={form.hourlyRate}
|
||||
onChange={e => set('hourlyRate', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!form.allowHourly && (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Разрешить бронирование номера на несколько часов
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Удобства
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AMENITY_LIST.map(a => {
|
||||
const selected = amenities.includes(a)
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,12 +5,17 @@ const DEFAULT_STATUSES: Record<string, ModuleStatus> = {
|
||||
'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<ModulesContextValue | null>(null)
|
||||
|
||||
const MODULES_VERSION = '2'
|
||||
|
||||
export function ModulesProvider({ children }: { children: React.ReactNode }) {
|
||||
const [statuses, setStatuses] = useState<Record<string, ModuleStatus>>(() => {
|
||||
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 {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -10,8 +10,14 @@ const CHANNEL_ICONS: Record<string, string> = {
|
||||
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,9 +87,13 @@ export function ChannelsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
{/* 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">
|
||||
{channels.map(channel => (
|
||||
{internationalChannels.map(channel => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
@@ -89,6 +102,24 @@ export function ChannelsPage() {
|
||||
/>
|
||||
))}
|
||||
</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 */}
|
||||
<div className="card p-4 border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/10">
|
||||
@@ -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
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>
|
||||
)
|
||||
}
|
||||
@@ -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,7 +163,7 @@ 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',
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user