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

@@ -18,6 +18,7 @@ import { BookingWidgetPage } from './pages/BookingWidgetPage'
import { AvailabilityPage } from './pages/AvailabilityPage' import { AvailabilityPage } from './pages/AvailabilityPage'
import { FloorMapPage } from './pages/FloorMapPage' import { FloorMapPage } from './pages/FloorMapPage'
import { AdminDashboard } from './pages/AdminDashboard' import { AdminDashboard } from './pages/AdminDashboard'
import { MigrationPage } from './pages/MigrationPage'
export default function App() { export default function App() {
return ( return (
@@ -45,6 +46,7 @@ export default function App() {
<Route path="/booking-widget" element={<BookingWidgetPage />} /> <Route path="/booking-widget" element={<BookingWidgetPage />} />
<Route path="/availability" element={<AvailabilityPage />} /> <Route path="/availability" element={<AvailabilityPage />} />
<Route path="/floor-map" element={<FloorMapPage />} /> <Route path="/floor-map" element={<FloorMapPage />} />
<Route path="/migration" element={<MigrationPage />} />
</Route> </Route>
{/* Admin routes */} {/* Admin routes */}

View File

@@ -8,6 +8,8 @@ import { FloorMapModal } from '../floormap/FloorMapModal'
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания'] const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
const HOURS = Array.from({ length: 24 }, (_, i) => i)
interface BookingModalProps { interface BookingModalProps {
open: boolean open: boolean
draft: DraftBooking draft: DraftBooking
@@ -35,11 +37,22 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0) const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0)
const [showFloorMap, setShowFloorMap] = useState(false) 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 room = rooms.find(r => r.id === form.roomId)
const nights = form.checkIn && form.checkOut const nights = form.checkIn && form.checkOut
? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000) ? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000)
: 0 : 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 debt = Math.max(0, total - paidAmount)
const isFutureBooking = form.checkIn > format(new Date(), 'yyyy-MM-dd') 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]) setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
const handleSave = () => { 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({ onSave({
...form, ...form,
checkIn,
checkOut,
notes: hourlyPrefix + form.notes,
totalAmount: total, totalAmount: total,
paidAmount, paidAmount,
id: existing?.id ?? `b-${Date.now()}`, 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 ( return (
<> <>
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'} title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
size="lg" size="2xl"
footer={ footer={
<> <>
<button onClick={onClose} className="btn-secondary">Отмена</button> <button onClick={onClose} className="btn-secondary">Отмена</button>
@@ -78,284 +109,374 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</> </>
} }
> >
<div className="space-y-4"> <div className="flex gap-6">
{/* Room */} {/* ── LEFT COLUMN ── */}
<div> <div className="flex-1 space-y-4 min-w-0">
<div className="flex items-center justify-between mb-1.5"> {/* Room */}
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Номер
</label>
<button
type="button"
onClick={() => setShowFloorMap(true)}
className="flex items-center gap-1 text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
<Map size={12} />
Поэтажный план
</button>
</div>
<select
value={form.roomId}
onChange={e => set('roomId', e.target.value)}
className="input"
>
{rooms.map(r => (
<option key={r.id} value={r.id}>
{r.number} {r.type} ({r.baseRate.toLocaleString('ru-RU')} /ночь)
</option>
))}
</select>
</div>
{/* Guest */}
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> <div className="flex items-center justify-between mb-1.5">
Имя гостя * <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
</label> Номер
<input </label>
type="text" <button
className="input" type="button"
placeholder="Иван Иванов" onClick={() => setShowFloorMap(true)}
value={form.guestName} className="flex items-center gap-1 text-xs text-brand-600 dark:text-brand-400 hover:underline"
onChange={e => set('guestName', e.target.value)} >
/> <Map size={12} />
</div> Поэтажный план
<div> </button>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> </div>
Email
</label>
<input
type="email"
className="input"
placeholder="guest@example.com"
value={form.guestEmail}
onChange={e => set('guestEmail', e.target.value)}
/>
</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 (
<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>
{/* Dates */}
<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="date"
className="input"
value={form.checkIn}
onChange={e => set('checkIn', 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="date"
className="input"
value={form.checkOut}
onChange={e => set('checkOut', e.target.value)}
/>
</div>
</div>
{/* Guests count */}
<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">
Взрослых
</label>
<input
type="number" min={1} max={room?.maxGuests ?? 6}
className="input"
value={form.adults}
onChange={e => set('adults', 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>
<input
type="number" min={0} max={4}
className="input"
value={form.children}
onChange={e => set('children', 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 <select
value={form.roomId}
onChange={e => set('roomId', e.target.value)}
className="input" className="input"
value={form.status}
onChange={e => set('status', e.target.value as BookingStatus)}
> >
{(Object.entries(BOOKING_STATUS_LABELS) as [BookingStatus, string][]).map(([k, v]) => ( {rooms.map(r => (
<option key={k} value={k}>{v}</option> <option key={r.id} value={r.id}>
{r.number} {r.type} ({r.baseRate.toLocaleString('ru-RU')} /ночь{r.allowHourly ? `, ${(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч` : ''})
</option>
))} ))}
</select> </select>
</div> </div>
</div>
{/* Source */} {/* Guest name + email */}
<div> <div className="grid grid-cols-2 gap-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> <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-2"> </label>
{(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => ( <input
type="text"
className="input"
placeholder="Иван Иванов"
value={form.guestName}
onChange={e => set('guestName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Email
</label>
<input
type="email"
className="input"
placeholder="guest@example.com"
value={form.guestEmail}
onChange={e => set('guestEmail', e.target.value)}
/>
</div>
</div>
{/* Hourly / Daily toggle */}
{showHourlyTab && (
<div className="flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600">
<button <button
key={k}
type="button" type="button"
onClick={() => set('source', k)} onClick={() => setIsHourly(false)}
className={cn( className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors', 'flex-1 py-1.5 text-sm font-medium transition-colors',
form.source === k !isHourly
? 'bg-brand-600 text-white border-brand-600' ? 'bg-brand-600 text-white'
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400', : 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
)} )}
> >
{v} Посуточно
</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> </button>
))}
</div>
</div>
{/* Payment */}
{nights > 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>
<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">
<button
type="button"
onClick={() => 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',
)}
>
<Banknote size={14} />
Наличные
</button>
<button
type="button"
onClick={() => 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',
)}
>
<CreditCard size={14} />
Терминал
</button>
</div>
</div>
{/* Amount paid */} {/* Dates */}
{isHourly && room?.allowHourly ? (
<div className="space-y-3">
<div> <div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Оплачено () Дата *
</label> </label>
<input <input
type="number" type="date"
min={0} className="input"
max={total} value={hourlyDate}
className="input w-40" onChange={e => setHourlyDate(e.target.value)}
value={paidAmount} />
onChange={e => setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))} </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">
Заезд *
</label>
<input
type="date"
className="input"
value={form.checkIn}
onChange={e => set('checkIn', 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="date"
className="input"
value={form.checkOut}
onChange={e => set('checkOut', e.target.value)}
/> />
</div> </div>
</div> </div>
</div> )}
)}
{/* Notes */} {/* Adults + Children + Status */}
<div> <div className="grid grid-cols-3 gap-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> <div>
Примечания <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
</label> Взрослых
<textarea </label>
className="input resize-none" <input
rows={2} type="number" min={1} max={room?.maxGuests ?? 6}
placeholder="Дополнительные пожелания..." className="input"
value={form.notes} value={form.adults}
onChange={e => set('notes', e.target.value)} onChange={e => set('adults', 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>
<input
type="number" min={0} max={4}
className="input"
value={form.children}
onChange={e => set('children', 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 BookingStatus)}
>
{(Object.entries(BOOKING_STATUS_LABELS) as [BookingStatus, string][]).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
</div>
</div> </div>
{/* Summary */} {/* ── RIGHT COLUMN ── */}
{nights > 0 && room && ( <div className="flex-1 space-y-4 min-w-0">
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5"> {/* Guest tags */}
<div className="flex items-center justify-between"> <div>
<span className="text-sm text-slate-600 dark:text-slate-300"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} Статус гостя
</span> </label>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100"> <div className="flex flex-wrap gap-1.5">
{total.toLocaleString('ru-RU')} {GUEST_TAGS.map(tag => {
</span> 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>
{paidAmount > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-600 dark:text-emerald-400">Оплачено</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{paidAmount.toLocaleString('ru-RU')}
</span>
</div>
)}
{debt > 0 && (
<div className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle size={13} />
<span className="flex-1">{isFutureBooking ? 'Задолженность' : 'Долг при заселении'}</span>
<span className="font-semibold">{debt.toLocaleString('ru-RU')} </span>
</div>
)}
</div> </div>
)}
{/* Source */}
<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-2">
{(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => 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}
</button>
))}
</div>
</div>
{/* Payment */}
{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">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Способ оплаты</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => 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',
)}
>
<Banknote size={14} />
Наличные
</button>
<button
type="button"
onClick={() => 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',
)}
>
<CreditCard size={14} />
Терминал
</button>
</div>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">
Оплачено ()
</label>
<input
type="number"
min={0}
max={total}
className="input w-40"
value={paidAmount}
onChange={e => setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
/>
</div>
</div>
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Примечания
</label>
<textarea
className="input resize-none"
rows={2}
placeholder="Дополнительные пожелания..."
value={form.notes}
onChange={e => set('notes', e.target.value)}
/>
</div>
{/* Summary */}
{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')}
</span>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
{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>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{paidAmount.toLocaleString('ru-RU')}
</span>
</div>
)}
{debt > 0 && (
<div className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle size={13} />
<span className="flex-1">{isFutureBooking ? 'Задолженность' : 'Долг при заселении'}</span>
<span className="font-semibold">{debt.toLocaleString('ru-RU')} </span>
</div>
)}
</div>
)}
</div>
</div> </div>
</Modal> </Modal>

View File

@@ -335,6 +335,8 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
if (!style) return null if (!style) return null
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn)) const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
const isFading = fadingBookingIds?.has(booking.id) const isFading = fadingBookingIds?.has(booking.id)
const isUnpaid = booking.paidAmount < booking.totalAmount
const guestCount = (booking.adults ?? 0) + (booking.children ?? 0)
return ( return (
<div <div
key={booking.id} key={booking.id}
@@ -351,14 +353,23 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
position: 'absolute', position: 'absolute',
}} }}
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }} 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"> <span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
{booking.guestName} {booking.guestName}
</span> </span>
{style.width > 80 && ( {style.width > 90 && guestCount > 0 && (
<span className="ml-2 opacity-75 text-[10px] shrink-0"> <span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{nights}н {SOURCE_LABELS[booking.source]} {guestCount}г
</span>
)}
{style.width > 130 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{nights}н
</span> </span>
)} )}
</div> </div>

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

View File

@@ -7,7 +7,7 @@ interface ModalProps {
onClose: () => void onClose: () => void
title: string title: string
children: React.ReactNode children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl' size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
footer?: React.ReactNode footer?: React.ReactNode
} }
@@ -16,6 +16,7 @@ const SIZES = {
md: 'max-w-md', md: 'max-w-md',
lg: 'max-w-lg', lg: 'max-w-lg',
xl: 'max-w-2xl', xl: 'max-w-2xl',
'2xl': 'max-w-3xl',
} }
export function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) { export function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) {

View File

@@ -5,12 +5,17 @@ const DEFAULT_STATUSES: Record<string, ModuleStatus> = {
'housekeeping': 'active', 'housekeeping': 'active',
'channel-manager': 'active', 'channel-manager': 'active',
'wifi-auth': 'active', 'wifi-auth': 'active',
'payments': 'trial', 'payments': 'active',
'tv-welcome': 'inactive', 'tv-welcome': 'active',
'smart-locks': 'inactive', 'smart-locks': 'active',
'olap-reports': 'active', 'olap-reports': 'active',
'website-builder': 'inactive', 'website-builder': 'active',
'booking-widget': 'inactive', 'booking-widget': 'active',
'pos': 'active',
'reviews': 'active',
'room-service': 'active',
'rental': 'active',
'migration': 'active',
} }
interface ModulesContextValue { interface ModulesContextValue {
@@ -21,9 +26,18 @@ interface ModulesContextValue {
const ModulesContext = createContext<ModulesContextValue | null>(null) const ModulesContext = createContext<ModulesContextValue | null>(null)
const MODULES_VERSION = '2'
export function ModulesProvider({ children }: { children: React.ReactNode }) { export function ModulesProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = useState<Record<string, ModuleStatus>>(() => { const [statuses, setStatuses] = useState<Record<string, ModuleStatus>>(() => {
try { 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') const stored = localStorage.getItem('hotelsync-modules')
return stored ? { ...DEFAULT_STATUSES, ...JSON.parse(stored) } : DEFAULT_STATUSES return stored ? { ...DEFAULT_STATUSES, ...JSON.parse(stored) } : DEFAULT_STATUSES
} catch { } catch {

View File

@@ -176,6 +176,50 @@ export const MOCK_CHANNELS: Channel[] = [
bookingsImported: 0, bookingsImported: 0,
mappings: [], 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 ────────────────────────────────────────────────────────────── // ─── Housekeeping ──────────────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
import { import {
Wifi, CreditCard, Tv2, KeyRound, Wifi, CreditCard, Tv2, KeyRound,
BarChart3, Globe, CalendarCheck2, Sparkles, Network, BarChart3, Globe, CalendarCheck2, Sparkles, Network,
ShoppingCart, Star, UtensilsCrossed, CalendarClock, ShoppingCart, Star, UtensilsCrossed, CalendarClock, ArrowLeftRight,
} from 'lucide-react' } from 'lucide-react'
import type { ElementType } from 'react' import type { ElementType } from 'react'
@@ -367,4 +367,29 @@ export const MODULES_DATA: ModuleDef[] = [
icon: CalendarClock, 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,
},
},
] ]

View File

@@ -6,12 +6,18 @@ import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
const CHANNEL_ICONS: Record<string, string> = { const CHANNEL_ICONS: Record<string, string> = {
booking_com: '🔵', booking_com: '🔵',
airbnb: '🔴', airbnb: '🔴',
expedia: '🟡', expedia: '🟡',
vrbo: '🟢', vrbo: '🟢',
yandex_travel: '🔴',
ostrovok: '🟣',
sutochno: '🟠',
onetwotrip: '🔵',
} }
const RUSSIAN_CHANNELS = new Set(['yandex_travel', 'ostrovok', 'sutochno', 'onetwotrip'])
function SyncStatusBadge({ status }: { status: SyncStatus }) { function SyncStatusBadge({ status }: { status: SyncStatus }) {
const map: Record<SyncStatus, { icon: React.ElementType; label: string; cls: string }> = { 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' }, 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 totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0)
const activeCount = channels.filter(c => c.isEnabled).length 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 ( return (
<div className="p-4 md:p-6 space-y-5"> <div className="p-4 md:p-6 space-y-5">
{/* Header */} {/* Header */}
@@ -78,16 +87,38 @@ export function ChannelsPage() {
))} ))}
</div> </div>
{/* Channels */} {/* International Channels */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div>
{channels.map(channel => ( <h2 className="text-base font-semibold text-slate-700 dark:text-slate-300 mb-3">
<ChannelCard 🌍 Международные площадки
key={channel.id} </h2>
channel={channel} <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
onSync={() => triggerSync(channel.id)} {internationalChannels.map(channel => (
onToggle={() => toggleChannel(channel.id)} <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> </div>
{/* Info */} {/* Info */}
@@ -167,7 +198,6 @@ function ChannelCard({ channel, onSync, onToggle }: {
{channel.mappings.slice(0, 2).map(m => ( {channel.mappings.slice(0, 2).map(m => (
<div key={m.localRoomId} className="flex items-center justify-between text-xs"> <div key={m.localRoomId} className="flex items-center justify-between text-xs">
<span className="text-slate-600 dark:text-slate-400">{ <span className="text-slate-600 dark:text-slate-400">{
// get room number from localRoomId
m.localRoomId.replace('r', '') m.localRoomId.replace('r', '')
}</span> }</span>
<span className="text-slate-400"></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 { 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 { MOCK_ROOMS } from '../data/mockData'
import type { Room } from '../types' import type { Room } from '../types'
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils' 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 { Badge } from '../components/ui/Badge'
import { RoomModal } from '../components/rooms/RoomModal'
export function RoomsPage() { export function RoomsPage() {
const [rooms, setRooms] = useState<Room[]>(MOCK_ROOMS)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all') 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 === '' || const matchSearch = search === '' ||
r.number.includes(search) || r.number.includes(search) ||
r.type.toLowerCase().includes(search.toLowerCase()) || r.type.toLowerCase().includes(search.toLowerCase()) ||
@@ -21,10 +25,33 @@ export function RoomsPage() {
}) })
const stats = { const stats = {
available: MOCK_ROOMS.filter(r => r.status === 'available').length, available: rooms.filter(r => r.status === 'available').length,
occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length, occupied: rooms.filter(r => r.status === 'occupied').length,
maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance').length, maintenance: rooms.filter(r => r.status === 'maintenance').length,
dirty: MOCK_ROOMS.filter(r => r.housekeepingStatus === 'dirty' || r.housekeepingStatus === 'cleaning').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 ( return (
@@ -33,9 +60,9 @@ export function RoomsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Номерной фонд</h1> <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> </div>
<button className="btn-primary"> <button className="btn-primary" onClick={openCreate}>
<Plus size={15} /> <Plus size={15} />
<span className="hidden sm:inline">Добавить номер</span> <span className="hidden sm:inline">Добавить номер</span>
</button> </button>
@@ -98,16 +125,32 @@ export function RoomsPage() {
{/* Room grid */} {/* Room grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(room => ( {filtered.map(room => (
<RoomCard key={room.id} room={room} /> <RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
))} ))}
</div> </div>
{/* Modal */}
<RoomModal
open={modalOpen}
room={editingRoom}
onClose={() => setModalOpen(false)}
onSave={handleSave}
/>
</div> </div>
) )
} }
function RoomCard({ room }: { room: Room }) { function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
return ( 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 className="flex items-start justify-between mb-3">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -120,11 +163,11 @@ function RoomCard({ room }: { room: Room }) {
</div> </div>
<span className="text-sm text-slate-500 dark:text-slate-400">{room.type} · Этаж {room.floor}</span> <span className="text-sm text-slate-500 dark:text-slate-400">{room.type} · Этаж {room.floor}</span>
</div> </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-emerald-500': room.status === 'available',
'bg-brand-500': room.status === 'occupied', 'bg-brand-500': room.status === 'occupied',
'bg-orange-500': room.status === 'maintenance', 'bg-orange-500': room.status === 'maintenance',
'bg-slate-400': room.status === 'blocked', 'bg-slate-400': room.status === 'blocked',
})} /> })} />
</div> </div>
@@ -158,7 +201,9 @@ function RoomCard({ room }: { room: Room }) {
<span className="text-base font-bold text-slate-900 dark:text-slate-100"> <span className="text-base font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(room.baseRate)} {formatCurrency(room.baseRate)}
</span> </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> </div>
{room.amenities.length > 0 && ( {room.amenities.length > 0 && (

View File

@@ -56,6 +56,8 @@ export interface Room {
housekeepingStatus: HousekeepingStatus housekeepingStatus: HousekeepingStatus
amenities: string[] amenities: string[]
sortOrder: number sortOrder: number
allowHourly?: boolean
hourlyRate?: number
} }
// ─── Guest ─────────────────────────────────────────────────────────────────── // ─── Guest ───────────────────────────────────────────────────────────────────
@@ -115,7 +117,7 @@ export interface DraftBooking {
// ─── Channel ───────────────────────────────────────────────────────────────── // ─── 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 type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'
export interface ChannelMapping { export interface ChannelMapping {