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 { 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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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
|
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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
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 { 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 && (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user