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:
@@ -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>
|
||||
|
||||
|
||||
@@ -335,6 +335,8 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
if (!style) return null
|
||||
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
|
||||
const isFading = fadingBookingIds?.has(booking.id)
|
||||
const isUnpaid = booking.paidAmount < booking.totalAmount
|
||||
const guestCount = (booking.adults ?? 0) + (booking.children ?? 0)
|
||||
return (
|
||||
<div
|
||||
key={booking.id}
|
||||
@@ -351,14 +353,23 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
position: 'absolute',
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }}
|
||||
title={`${booking.guestName} • ${booking.checkIn} – ${booking.checkOut}`}
|
||||
title={`${booking.guestName} • ${booking.checkIn} – ${booking.checkOut}${isUnpaid ? ' • Не оплачено' : ''}`}
|
||||
>
|
||||
{/* Unpaid indicator */}
|
||||
{isUnpaid && (
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-500 shadow-sm mr-1 mt-0.5" />
|
||||
)}
|
||||
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
|
||||
{booking.guestName}
|
||||
</span>
|
||||
{style.width > 80 && (
|
||||
<span className="ml-2 opacity-75 text-[10px] shrink-0">
|
||||
{nights}н • {SOURCE_LABELS[booking.source]}
|
||||
{style.width > 90 && guestCount > 0 && (
|
||||
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
|
||||
{guestCount}г
|
||||
</span>
|
||||
)}
|
||||
{style.width > 130 && (
|
||||
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
|
||||
{nights}н
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
326
src/components/rooms/RoomModal.tsx
Normal file
326
src/components/rooms/RoomModal.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '../ui/Modal'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { Room, RoomStatus, HousekeepingStatus, BedType } from '../../types'
|
||||
|
||||
const AMENITY_LIST = [
|
||||
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
|
||||
'Panoramic view', 'Kitchen', 'Washing machine', 'Butler', 'Terrace',
|
||||
]
|
||||
|
||||
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
|
||||
|
||||
const BED_TYPES: { value: BedType; label: string }[] = [
|
||||
{ value: 'single', label: 'Одна кровать' },
|
||||
{ value: 'double', label: 'Двуспальная' },
|
||||
{ value: 'queen', label: 'Queen' },
|
||||
{ value: 'king', label: 'King' },
|
||||
{ value: 'twin', label: 'Две кровати' },
|
||||
]
|
||||
|
||||
const ROOM_STATUSES: { value: RoomStatus; label: string }[] = [
|
||||
{ value: 'available', label: 'Свободен' },
|
||||
{ value: 'occupied', label: 'Занят' },
|
||||
{ value: 'maintenance', label: 'Ремонт' },
|
||||
{ value: 'blocked', label: 'Закрыт' },
|
||||
]
|
||||
|
||||
const HK_STATUSES: { value: HousekeepingStatus; label: string }[] = [
|
||||
{ value: 'clean', label: 'Чистый' },
|
||||
{ value: 'dirty', label: 'Грязный' },
|
||||
{ value: 'cleaning', label: 'Убирается' },
|
||||
{ value: 'inspect', label: 'Проверка' },
|
||||
]
|
||||
|
||||
interface RoomModalProps {
|
||||
open: boolean
|
||||
room?: Room
|
||||
onClose: () => void
|
||||
onSave: (room: Room) => void
|
||||
}
|
||||
|
||||
export function RoomModal({ open, room, onClose, onSave }: RoomModalProps) {
|
||||
const isEdit = !!room
|
||||
|
||||
const [form, setForm] = useState({
|
||||
number: room?.number ?? '',
|
||||
name: room?.name ?? '',
|
||||
floor: room?.floor ?? 1,
|
||||
type: room?.type ?? 'Стандарт',
|
||||
bedType: (room?.bedType ?? 'double') as BedType,
|
||||
maxGuests: room?.maxGuests ?? 2,
|
||||
baseRate: room?.baseRate ?? 5000,
|
||||
status: (room?.status ?? 'available') as RoomStatus,
|
||||
housekeepingStatus: (room?.housekeepingStatus ?? 'clean') as HousekeepingStatus,
|
||||
sortOrder: room?.sortOrder ?? 99,
|
||||
allowHourly: room?.allowHourly ?? false,
|
||||
hourlyRate: room?.hourlyRate ?? 1000,
|
||||
})
|
||||
|
||||
const [amenities, setAmenities] = useState<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
|
||||
|
||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }))
|
||||
|
||||
const toggleAmenity = (a: string) =>
|
||||
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.number) return
|
||||
const saved: Room = {
|
||||
id: room?.id ?? `r-${Date.now()}`,
|
||||
hotelId: room?.hotelId ?? 'hotel-1',
|
||||
number: form.number,
|
||||
name: form.name || undefined,
|
||||
floor: form.floor,
|
||||
type: form.type,
|
||||
bedType: form.bedType,
|
||||
maxGuests: form.maxGuests,
|
||||
baseRate: form.baseRate,
|
||||
status: form.status,
|
||||
housekeepingStatus: form.housekeepingStatus,
|
||||
amenities,
|
||||
sortOrder: form.sortOrder,
|
||||
allowHourly: form.allowHourly || undefined,
|
||||
hourlyRate: form.allowHourly ? form.hourlyRate : undefined,
|
||||
}
|
||||
onSave(saved)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? `Редактировать номер ${room.number}` : 'Добавить номер'}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={handleSave} className="btn-primary" disabled={!form.number}>
|
||||
{isEdit ? 'Сохранить' : 'Добавить номер'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Number | Name */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Номер *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="101"
|
||||
value={form.number}
|
||||
onChange={e => set('number', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Пентхаус"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Floor | Type */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Этаж
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
className="input"
|
||||
value={form.floor}
|
||||
onChange={e => set('floor', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Тип номера
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.type}
|
||||
onChange={e => set('type', e.target.value)}
|
||||
>
|
||||
{ROOM_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Bed type | Max guests */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Тип кровати
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.bedType}
|
||||
onChange={e => set('bedType', e.target.value as BedType)}
|
||||
>
|
||||
{BED_TYPES.map(b => (
|
||||
<option key={b.value} value={b.value}>{b.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Макс. гостей
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="input"
|
||||
value={form.maxGuests}
|
||||
onChange={e => set('maxGuests', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Base rate | Status */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Цена ₽/ночь
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input"
|
||||
value={form.baseRate}
|
||||
onChange={e => set('baseRate', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус номера
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.status}
|
||||
onChange={e => set('status', e.target.value as RoomStatus)}
|
||||
>
|
||||
{ROOM_STATUSES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Housekeeping | Sort order */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус уборки
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.housekeepingStatus}
|
||||
onChange={e => set('housekeepingStatus', e.target.value as HousekeepingStatus)}
|
||||
>
|
||||
{HK_STATUSES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Порядок сортировки
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input"
|
||||
value={form.sortOrder}
|
||||
onChange={e => set('sortOrder', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly section */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Почасовая аренда</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('allowHourly', !form.allowHourly)}
|
||||
className={cn(
|
||||
'relative w-11 h-6 rounded-full transition-colors',
|
||||
form.allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
form.allowHourly ? 'left-[22px]' : 'left-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
{form.allowHourly && (
|
||||
<div className="p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Цена ₽/час
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input w-40"
|
||||
value={form.hourlyRate}
|
||||
onChange={e => set('hourlyRate', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!form.allowHourly && (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Разрешить бронирование номера на несколько часов
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Удобства
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AMENITY_LIST.map(a => {
|
||||
const selected = amenities.includes(a)
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => toggleAmenity(a)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
selected
|
||||
? 'bg-brand-600 text-white border-brand-600'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
|
||||
)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ interface ModalProps {
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const SIZES = {
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
'2xl': 'max-w-3xl',
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) {
|
||||
|
||||
Reference in New Issue
Block a user