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

@@ -8,6 +8,8 @@ import { FloorMapModal } from '../floormap/FloorMapModal'
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
const HOURS = Array.from({ length: 24 }, (_, i) => i)
interface BookingModalProps {
open: boolean
draft: DraftBooking
@@ -35,11 +37,22 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0)
const [showFloorMap, setShowFloorMap] = useState(false)
// Hourly booking state
const [isHourly, setIsHourly] = useState(false)
const [hourlyDate, setHourlyDate] = useState(draft.checkIn)
const [startHour, setStartHour] = useState(10)
const [endHour, setEndHour] = useState(12)
const room = rooms.find(r => r.id === form.roomId)
const nights = form.checkIn && form.checkOut
? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000)
: 0
const total = (room?.baseRate ?? 0) * nights
const hourlyHours = Math.max(0, endHour - startHour)
const total = isHourly && room?.allowHourly
? (room.hourlyRate ?? 0) * hourlyHours
: (room?.baseRate ?? 0) * nights
const debt = Math.max(0, total - paidAmount)
const isFutureBooking = form.checkIn > format(new Date(), 'yyyy-MM-dd')
@@ -50,9 +63,25 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
const handleSave = () => {
if (!form.guestName || !form.checkIn || !form.checkOut) return
if (!form.guestName) return
if (isHourly && room?.allowHourly) {
if (!hourlyDate) return
} else {
if (!form.checkIn || !form.checkOut) return
}
const hourlyPrefix = isHourly && room?.allowHourly
? `[Почасово: ${String(startHour).padStart(2, '0')}:00${String(endHour).padStart(2, '0')}:00] `
: ''
const checkIn = isHourly && room?.allowHourly ? hourlyDate : form.checkIn
const checkOut = isHourly && room?.allowHourly ? hourlyDate : form.checkOut
onSave({
...form,
checkIn,
checkOut,
notes: hourlyPrefix + form.notes,
totalAmount: total,
paidAmount,
id: existing?.id ?? `b-${Date.now()}`,
@@ -62,13 +91,15 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
})
}
const showHourlyTab = room?.allowHourly === true
return (
<>
<Modal
open={open}
onClose={onClose}
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
size="lg"
size="2xl"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
@@ -78,284 +109,374 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</>
}
>
<div className="space-y-4">
{/* Room */}
<div>
<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>
<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 className="flex gap-6">
{/* ── LEFT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0">
{/* Room */}
<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.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>
{/* 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>
<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>
<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"
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>
{rooms.map(r => (
<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>
</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]) => (
{/* Guest name + email */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Имя гостя *
</label>
<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
key={k}
type="button"
onClick={() => set('source', k)}
onClick={() => setIsHourly(false)}
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',
'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',
)}
>
{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>
))}
</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 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>
<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>
<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)))}
type="date"
className="input"
value={hourlyDate}
onChange={e => setHourlyDate(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Начало
</label>
<select
className="input"
value={startHour}
onChange={e => setStartHour(parseInt(e.target.value))}
>
{HOURS.map(h => (
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Конец
</label>
<select
className="input"
value={endHour}
onChange={e => setEndHour(parseInt(e.target.value))}
>
{HOURS.filter(h => h > startHour).map(h => (
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
))}
</select>
</div>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Заезд *
</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>
)}
)}
{/* 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)}
/>
{/* Adults + Children + Status */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Взрослых
</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
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>
{/* Summary */}
{nights > 0 && room && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5">
<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>
{/* ── RIGHT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0">
{/* Guest tags */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Статус гостя
</label>
<div className="flex flex-wrap gap-1.5">
{GUEST_TAGS.map(tag => {
const isVip = tag === 'VIP'
const selected = guestTags.includes(tag)
return (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={cn(
'flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
selected
? isVip
? 'bg-amber-500 border-amber-500 text-white'
: 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{isVip && <Star size={10} className={selected ? 'text-white' : 'text-amber-500'} />}
{tag}
</button>
)
})}
</div>
{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>
)}
{/* 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>
</Modal>

View File

@@ -335,6 +335,8 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
if (!style) return null
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
const isFading = fadingBookingIds?.has(booking.id)
const isUnpaid = booking.paidAmount < booking.totalAmount
const guestCount = (booking.adults ?? 0) + (booking.children ?? 0)
return (
<div
key={booking.id}
@@ -351,14 +353,23 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
position: 'absolute',
}}
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }}
title={`${booking.guestName}${booking.checkIn} ${booking.checkOut}`}
title={`${booking.guestName}${booking.checkIn} ${booking.checkOut}${isUnpaid ? ' • Не оплачено' : ''}`}
>
{/* Unpaid indicator */}
{isUnpaid && (
<span className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-500 shadow-sm mr-1 mt-0.5" />
)}
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
{booking.guestName}
</span>
{style.width > 80 && (
<span className="ml-2 opacity-75 text-[10px] shrink-0">
{nights}н {SOURCE_LABELS[booking.source]}
{style.width > 90 && guestCount > 0 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{guestCount}г
</span>
)}
{style.width > 130 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{nights}н
</span>
)}
</div>

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