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:
2026-03-11 17:08:10 +03:00
parent 74e0223c3c
commit 57c22be60d
11 changed files with 1432 additions and 297 deletions

View File

@@ -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)}
/>
)}
</>
)
}