Add floor map, rental module wiring, VIP tags and payment tracking in BookingModal
- New: Поэтажный план (/floor-map) — room status visualization per floor with color coding (occupied/arriving/departing/available/dirty/maintenance), click free room to create booking - New: FloorMapModal — embeddable in BookingModal via "Поэтажный план" button near room selector - New: RentalBookingModal — full-day toggle, hour start/end selects, conflict detection, price summary - CalendarPage: rental objects/bookings shown in шахматка when rental module is active - BookingsPage: "Аренда" tab with rental bookings table when rental module is active - BookingModal: guest tags (VIP, Постоянный гость, etc.), payment method (cash/terminal), paidAmount input, debt/задолженность display, floor map quick-access button - Sidebar: "План этажей" nav link added under Управление Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Star, Banknote, CreditCard, AlertCircle, Map } from 'lucide-react'
|
||||
import { Modal } from '../ui/Modal'
|
||||
import { cn, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Booking, DraftBooking, Room, BookingStatus, BookingSource } from '../../types'
|
||||
import { FloorMapModal } from '../floormap/FloorMapModal'
|
||||
|
||||
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
|
||||
|
||||
interface BookingModalProps {
|
||||
open: boolean
|
||||
@@ -15,33 +19,42 @@ interface BookingModalProps {
|
||||
|
||||
export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: BookingModalProps) {
|
||||
const [form, setForm] = useState({
|
||||
roomId: existing?.roomId ?? draft.roomId,
|
||||
guestName: existing?.guestName ?? '',
|
||||
guestEmail: existing?.guestEmail ?? '',
|
||||
checkIn: existing?.checkIn ?? draft.checkIn,
|
||||
checkOut: existing?.checkOut ?? draft.checkOut,
|
||||
adults: existing?.adults ?? 2,
|
||||
children: existing?.children ?? 0,
|
||||
source: (existing?.source ?? 'direct') as BookingSource,
|
||||
status: (existing?.status ?? 'confirmed') as BookingStatus,
|
||||
notes: existing?.notes ?? '',
|
||||
roomId: existing?.roomId ?? draft.roomId,
|
||||
guestName: existing?.guestName ?? '',
|
||||
guestEmail: existing?.guestEmail ?? '',
|
||||
checkIn: existing?.checkIn ?? draft.checkIn,
|
||||
checkOut: existing?.checkOut ?? draft.checkOut,
|
||||
adults: existing?.adults ?? 2,
|
||||
children: existing?.children ?? 0,
|
||||
source: (existing?.source ?? 'direct') as BookingSource,
|
||||
status: (existing?.status ?? 'confirmed') as BookingStatus,
|
||||
notes: existing?.notes ?? '',
|
||||
})
|
||||
const [guestTags, setGuestTags] = useState<string[]>([])
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'terminal' | null>(null)
|
||||
const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0)
|
||||
const [showFloorMap, setShowFloorMap] = useState(false)
|
||||
|
||||
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 debt = Math.max(0, total - paidAmount)
|
||||
const isFutureBooking = form.checkIn > format(new Date(), 'yyyy-MM-dd')
|
||||
|
||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }))
|
||||
|
||||
const toggleTag = (tag: string) =>
|
||||
setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.guestName || !form.checkIn || !form.checkOut) return
|
||||
onSave({
|
||||
...form,
|
||||
totalAmount: total,
|
||||
paidAmount: existing?.paidAmount ?? 0,
|
||||
paidAmount,
|
||||
id: existing?.id ?? `b-${Date.now()}`,
|
||||
hotelId: 'hotel-1',
|
||||
guestId: existing?.guestId ?? `g-${Date.now()}`,
|
||||
@@ -50,183 +63,310 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={handleSave} className="btn-primary" disabled={!form.guestName}>
|
||||
{existing ? 'Сохранить' : 'Создать бронирование'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Room */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Номер
|
||||
</label>
|
||||
<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">
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={handleSave} className="btn-primary" disabled={!form.guestName}>
|
||||
{existing ? 'Сохранить' : 'Создать бронирование'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 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>
|
||||
|
||||
{/* 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')} ₽/ночь)
|
||||
</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]) => (
|
||||
<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',
|
||||
)}
|
||||
{/* Guest */}
|
||||
<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>
|
||||
|
||||
{/* 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
|
||||
className="input"
|
||||
value={form.status}
|
||||
onChange={e => set('status', e.target.value as BookingStatus)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
{(Object.entries(BOOKING_STATUS_LABELS) as [BookingStatus, string][]).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</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 */}
|
||||
{nights > 0 && room && (
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 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>
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 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 */}
|
||||
<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 */}
|
||||
{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>
|
||||
</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>
|
||||
</Modal>
|
||||
|
||||
{showFloorMap && (
|
||||
<FloorMapModal
|
||||
rooms={rooms}
|
||||
selectedRoomId={form.roomId}
|
||||
onSelectRoom={(id) => { set('roomId', id); setShowFloorMap(false) }}
|
||||
onClose={() => setShowFloorMap(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user