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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { ru } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } from 'lucide-react'
|
||||
import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Room, Booking, DraftBooking } from '../../types'
|
||||
import type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||||
import { BookingModal } from '../bookings/BookingModal'
|
||||
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
||||
import { RentalBookingModal } from '../rental/RentalBookingModal'
|
||||
|
||||
const CELL_WIDTH = 52
|
||||
const ROW_HEIGHT = 56
|
||||
@@ -18,6 +20,9 @@ interface BookingCalendarProps {
|
||||
onBookingCreate: (b: Partial<Booking>) => void
|
||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||
fadingBookingIds?: Set<string>
|
||||
rentalObjects?: RentalObject[]
|
||||
rentalBookings?: RentalBooking[]
|
||||
onRentalBookingCreate?: (b: RentalBooking) => void
|
||||
}
|
||||
|
||||
function getRoomTypeColor(type: string): string {
|
||||
@@ -32,7 +37,7 @@ function getRoomTypeColor(type: string): string {
|
||||
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
||||
}
|
||||
|
||||
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, fadingBookingIds }: BookingCalendarProps) {
|
||||
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate }: BookingCalendarProps) {
|
||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
||||
|
||||
@@ -60,6 +65,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
// Modals
|
||||
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null)
|
||||
const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null)
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -370,6 +376,124 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Rental section ── */}
|
||||
{rentalObjects && rentalObjects.length > 0 && (
|
||||
<>
|
||||
{/* Separator */}
|
||||
<div className="flex sticky left-0 bg-slate-100 dark:bg-slate-700/60 border-b border-t border-slate-200 dark:border-slate-600"
|
||||
style={{ height: 32 }}>
|
||||
<div
|
||||
className="shrink-0 sticky left-0 z-10 bg-slate-100 dark:bg-slate-700/60 border-r border-slate-200 dark:border-slate-600 flex items-center px-4"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
Аренда объектов
|
||||
</span>
|
||||
</div>
|
||||
{dates.map((_, i) => (
|
||||
<div key={i} className="shrink-0 border-r border-slate-200 dark:border-slate-600"
|
||||
style={{ width: CELL_WIDTH }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rental object rows */}
|
||||
{rentalObjects.map(obj => {
|
||||
const totalSpan = obj.closeHour - obj.openHour
|
||||
return (
|
||||
<div key={obj.id}
|
||||
className="flex border-b border-slate-200 dark:border-slate-700"
|
||||
style={{ height: ROW_HEIGHT }}>
|
||||
{/* Label */}
|
||||
<div
|
||||
className="shrink-0 sticky left-0 z-10 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center gap-2 px-3"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<span className="text-lg">{obj.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{obj.name}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{obj.pricePerHour.toLocaleString('ru-RU')} ₽/ч · {obj.pricePerDay.toLocaleString('ru-RU')} ₽/день
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
{dates.map((date, i) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd')
|
||||
const isWe = date.getDay() === 0 || date.getDay() === 6
|
||||
const isTod = isToday(date)
|
||||
const dayBookings = (rentalBookings ?? []).filter(
|
||||
b => b.objectId === obj.id && b.date === dateStr && b.status !== 'cancelled',
|
||||
)
|
||||
const hasFullDay = dayBookings.some(b => b.isFullDay)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setRentalModal({ obj, date: dateStr })}
|
||||
className={cn(
|
||||
'shrink-0 border-r border-slate-200 dark:border-slate-700 cursor-pointer relative',
|
||||
'hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors',
|
||||
isWe && 'bg-amber-50/30 dark:bg-amber-900/10',
|
||||
isTod && 'bg-brand-50/40 dark:bg-brand-900/10',
|
||||
)}
|
||||
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
|
||||
>
|
||||
{hasFullDay ? (
|
||||
/* Full day booking */
|
||||
<div className={cn(
|
||||
'absolute inset-1.5 rounded flex items-center justify-center text-white text-[10px] font-medium',
|
||||
obj.color,
|
||||
)}>
|
||||
Весь день
|
||||
</div>
|
||||
) : dayBookings.length > 0 ? (
|
||||
/* Hourly booking bars */
|
||||
<div className="absolute inset-x-1 bottom-1" style={{ top: 6 }}>
|
||||
{/* Time scale bar (background) */}
|
||||
<div className="w-full h-2.5 rounded-sm bg-slate-100 dark:bg-slate-600 relative overflow-hidden">
|
||||
{dayBookings.map(b => {
|
||||
const leftPct = ((b.startHour - obj.openHour) / totalSpan) * 100
|
||||
const widthPct = ((b.endHour - b.startHour) / totalSpan) * 100
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={cn('absolute h-full rounded-sm', obj.color)}
|
||||
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
|
||||
title={`${b.guestName} · ${b.startHour}:00–${b.endHour}:00`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Hour labels */}
|
||||
<div className="flex justify-between mt-0.5 px-0.5">
|
||||
<span className="text-[9px] text-slate-400">{obj.openHour}:00</span>
|
||||
<span className="text-[9px] text-slate-400">{obj.closeHour}:00</span>
|
||||
</div>
|
||||
{/* Booking count */}
|
||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||
{dayBookings.map(b => (
|
||||
<span key={b.id} className="text-[9px] text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-1 rounded truncate max-w-full">
|
||||
{b.startHour}–{b.endHour}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty — show "+" hint on hover */
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs text-slate-400">+</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,6 +523,22 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rental booking modal */}
|
||||
{rentalModal && (
|
||||
<RentalBookingModal
|
||||
obj={rentalModal.obj}
|
||||
date={rentalModal.date}
|
||||
existingBookings={(rentalBookings ?? []).filter(
|
||||
b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date,
|
||||
)}
|
||||
onClose={() => setRentalModal(null)}
|
||||
onSave={(b) => {
|
||||
onRentalBookingCreate?.(b)
|
||||
setRentalModal(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
213
src/components/floormap/FloorMap.tsx
Normal file
213
src/components/floormap/FloorMap.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { BedDouble, Wrench, Lock, Sparkles } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { Room, Booking } from '../../types'
|
||||
|
||||
const TODAY = format(new Date(), 'yyyy-MM-dd')
|
||||
|
||||
type FloorStatus = 'occupied' | 'arriving' | 'departing' | 'available' | 'dirty' | 'maintenance' | 'blocked'
|
||||
|
||||
interface RoomWithStatus extends Room {
|
||||
floorStatus: FloorStatus
|
||||
activeBooking?: Booking
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<FloorStatus, {
|
||||
label: string
|
||||
bg: string
|
||||
border: string
|
||||
text: string
|
||||
dot: string
|
||||
}> = {
|
||||
occupied: { label: 'Занят', bg: 'bg-red-50 dark:bg-red-900/20', border: 'border-red-300 dark:border-red-700', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
|
||||
arriving: { label: 'Заезд сегодня', bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-300 dark:border-blue-700', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
|
||||
departing: { label: 'Выезд сегодня', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-300 dark:border-amber-700', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
available: { label: 'Свободен', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-300 dark:border-emerald-700', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
|
||||
dirty: { label: 'Уборка', bg: 'bg-orange-50 dark:bg-orange-900/20', border: 'border-orange-300 dark:border-orange-700', text: 'text-orange-700 dark:text-orange-300', dot: 'bg-orange-500' },
|
||||
maintenance: { label: 'Ремонт', bg: 'bg-slate-100 dark:bg-slate-700', border: 'border-slate-300 dark:border-slate-600', text: 'text-slate-500 dark:text-slate-400', dot: 'bg-slate-400' },
|
||||
blocked: { label: 'Закрыт', bg: 'bg-slate-100 dark:bg-slate-700', border: 'border-slate-300 dark:border-slate-600', text: 'text-slate-500 dark:text-slate-400', dot: 'bg-slate-400' },
|
||||
}
|
||||
|
||||
const LEGEND: FloorStatus[] = ['available', 'occupied', 'arriving', 'departing', 'dirty', 'maintenance']
|
||||
|
||||
function getRoomFloorStatus(room: Room, bookings: Booking[]): { floorStatus: FloorStatus; activeBooking?: Booking } {
|
||||
if (room.status === 'maintenance') return { floorStatus: 'maintenance' }
|
||||
if (room.status === 'blocked') return { floorStatus: 'blocked' }
|
||||
|
||||
const roomBookings = bookings.filter(b => b.roomId === room.id && b.status !== 'cancelled')
|
||||
const checkedIn = roomBookings.find(b => b.status === 'checked_in')
|
||||
const arrivingToday = roomBookings.find(b => b.checkIn === TODAY && b.status === 'confirmed')
|
||||
|
||||
if (checkedIn) {
|
||||
const isDeparting = checkedIn.checkOut === TODAY
|
||||
return { floorStatus: isDeparting ? 'departing' : 'occupied', activeBooking: checkedIn }
|
||||
}
|
||||
if (arrivingToday) return { floorStatus: 'arriving', activeBooking: arrivingToday }
|
||||
|
||||
if (room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') {
|
||||
return { floorStatus: 'dirty' }
|
||||
}
|
||||
return { floorStatus: 'available' }
|
||||
}
|
||||
|
||||
interface FloorMapProps {
|
||||
rooms: Room[]
|
||||
bookings: Booking[]
|
||||
selectedRoomId?: string
|
||||
onSelectRoom?: (id: string) => void
|
||||
}
|
||||
|
||||
export function FloorMap({ rooms, bookings, selectedRoomId, onSelectRoom }: FloorMapProps) {
|
||||
const [activeFloor, setActiveFloor] = useState<number | null>(null)
|
||||
const [hoveredRoom, setHoveredRoom] = useState<string | null>(null)
|
||||
|
||||
const roomsWithStatus: RoomWithStatus[] = rooms.map(r => ({
|
||||
...r,
|
||||
...getRoomFloorStatus(r, bookings),
|
||||
}))
|
||||
|
||||
const floors = [...new Set(rooms.map(r => r.floor))].sort((a, b) => a - b)
|
||||
const selectedFloor = activeFloor ?? floors[0]
|
||||
const floorRooms = roomsWithStatus.filter(r => r.floor === selectedFloor)
|
||||
|
||||
const floorStats = (floor: number) => {
|
||||
const fr = roomsWithStatus.filter(r => r.floor === floor)
|
||||
const occupied = fr.filter(r => r.floorStatus === 'occupied' || r.floorStatus === 'departing').length
|
||||
const available = fr.filter(r => r.floorStatus === 'available').length
|
||||
return { total: fr.length, occupied, available }
|
||||
}
|
||||
|
||||
const hoveredRoomData = hoveredRoom ? roomsWithStatus.find(r => r.id === hoveredRoom) : null
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Floor tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{floors.map(floor => {
|
||||
const stats = floorStats(floor)
|
||||
return (
|
||||
<button
|
||||
key={floor}
|
||||
onClick={() => setActiveFloor(floor)}
|
||||
className={cn(
|
||||
'flex flex-col items-center px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all',
|
||||
selectedFloor === floor
|
||||
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
|
||||
: 'border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:border-slate-300',
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">{floor} этаж</span>
|
||||
<span className="text-[10px] text-slate-400 font-normal mt-0.5">
|
||||
{stats.occupied}/{stats.total} занято
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Floor plan */}
|
||||
<div className="relative">
|
||||
{/* Corridor bar */}
|
||||
<div className="w-full h-7 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center mb-3">
|
||||
<span className="text-xs text-slate-400 font-medium uppercase tracking-wide">Коридор</span>
|
||||
</div>
|
||||
|
||||
{/* Room grid */}
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))' }}>
|
||||
{floorRooms.map(room => {
|
||||
const cfg = STATUS_CONFIG[room.floorStatus]
|
||||
const isSelected = selectedRoomId === room.id
|
||||
const isHovered = hoveredRoom === room.id
|
||||
const isClickable = !!onSelectRoom && (room.floorStatus === 'available')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
onMouseEnter={() => setHoveredRoom(room.id)}
|
||||
onMouseLeave={() => setHoveredRoom(null)}
|
||||
onClick={() => isClickable && onSelectRoom(room.id)}
|
||||
className={cn(
|
||||
'relative rounded-xl border-2 p-3 transition-all select-none',
|
||||
cfg.bg, cfg.border,
|
||||
isSelected && 'ring-2 ring-brand-500 ring-offset-2',
|
||||
isClickable ? 'cursor-pointer hover:scale-105' : onSelectRoom ? 'cursor-not-allowed opacity-70' : 'cursor-default',
|
||||
)}
|
||||
style={{ minHeight: 100 }}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span className={cn('absolute top-2.5 right-2.5 w-2 h-2 rounded-full', cfg.dot)} />
|
||||
|
||||
{/* Room icon */}
|
||||
<div className="mb-2">
|
||||
{room.floorStatus === 'maintenance' ? (
|
||||
<Wrench size={18} className="text-slate-400" />
|
||||
) : room.floorStatus === 'blocked' ? (
|
||||
<Lock size={18} className="text-slate-400" />
|
||||
) : room.floorStatus === 'occupied' || room.floorStatus === 'arriving' || room.floorStatus === 'departing' ? (
|
||||
<BedDouble size={18} className={cfg.text} />
|
||||
) : (
|
||||
<BedDouble size={18} className="text-emerald-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room number */}
|
||||
<p className={cn('text-base font-bold', cfg.text)}>
|
||||
{room.name ?? `№${room.number}`}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 truncate">{room.type}</p>
|
||||
|
||||
{/* Status label */}
|
||||
<p className={cn('text-[10px] font-semibold mt-1.5', cfg.text)}>
|
||||
{cfg.label}
|
||||
</p>
|
||||
|
||||
{/* Guest name if occupied */}
|
||||
{room.activeBooking && (isHovered || isSelected) && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-current/20">
|
||||
<p className="text-[10px] text-slate-600 dark:text-slate-300 font-medium truncate">
|
||||
{room.activeBooking.guestName}
|
||||
</p>
|
||||
{room.floorStatus === 'departing' && (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">Выезд сегодня</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected checkmark */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 rounded-xl bg-brand-600/10 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-brand-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stairwell / elevator hint */}
|
||||
<div className="mt-3 flex gap-2 justify-end">
|
||||
<div className="w-8 h-14 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<span className="text-[9px] text-slate-400 rotate-90 tracking-wider">Лифт</span>
|
||||
</div>
|
||||
<div className="w-8 h-14 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<span className="text-[9px] text-slate-400 rotate-90 tracking-wider">Лестн.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t border-slate-200 dark:border-slate-700">
|
||||
{LEGEND.map(status => {
|
||||
const cfg = STATUS_CONFIG[status]
|
||||
return (
|
||||
<div key={status} className="flex items-center gap-1.5">
|
||||
<span className={cn('w-2.5 h-2.5 rounded-full', cfg.dot)} />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">{cfg.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/components/floormap/FloorMapModal.tsx
Normal file
39
src/components/floormap/FloorMapModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { FloorMap } from './FloorMap'
|
||||
import type { Room } from '../../types'
|
||||
import { MOCK_BOOKINGS } from '../../data/mockData'
|
||||
|
||||
interface FloorMapModalProps {
|
||||
rooms: Room[]
|
||||
selectedRoomId?: string
|
||||
onSelectRoom?: (id: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FloorMapModal({ rooms, selectedRoomId, onSelectRoom, onClose }: FloorMapModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Поэтажный план</h2>
|
||||
{onSelectRoom && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Выберите номер для бронирования</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost p-1.5">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<FloorMap
|
||||
rooms={rooms}
|
||||
bookings={MOCK_BOOKINGS}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={onSelectRoom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useModules } from '../../contexts/ModulesContext'
|
||||
@@ -135,6 +135,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
Управление
|
||||
</p>
|
||||
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
|
||||
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
|
||||
{isModuleActive('channel-manager') && (
|
||||
<NavItem
|
||||
|
||||
250
src/components/rental/RentalBookingModal.tsx
Normal file
250
src/components/rental/RentalBookingModal.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Clock, CalendarDays, User, Phone, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||||
|
||||
interface RentalBookingModalProps {
|
||||
obj: RentalObject
|
||||
date: string // 'yyyy-MM-dd'
|
||||
existingBookings: RentalBooking[]
|
||||
onClose: () => void
|
||||
onSave: (b: RentalBooking) => void
|
||||
}
|
||||
|
||||
function hourOptions(from: number, to: number): number[] {
|
||||
return Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||
}
|
||||
|
||||
function formatHour(h: number): string {
|
||||
return `${h}:00`
|
||||
}
|
||||
|
||||
export function RentalBookingModal({ obj, date, existingBookings, onClose, onSave }: RentalBookingModalProps) {
|
||||
const [isFullDay, setIsFullDay] = useState(false)
|
||||
const [startHour, setStartHour] = useState(obj.openHour)
|
||||
const [endHour, setEndHour] = useState(Math.min(obj.openHour + 2, obj.closeHour))
|
||||
const [guestName, setGuestName] = useState('')
|
||||
const [guestPhone, setGuestPhone] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const hours = isFullDay
|
||||
? obj.closeHour - obj.openHour
|
||||
: Math.max(0, endHour - startHour)
|
||||
|
||||
const totalAmount = isFullDay
|
||||
? obj.pricePerDay
|
||||
: hours * obj.pricePerHour
|
||||
|
||||
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
|
||||
|
||||
const isTimeConflict = !isFullDay && existingBookings.some(b =>
|
||||
!b.isFullDay && b.status !== 'cancelled' &&
|
||||
b.startHour < endHour && b.endHour > startHour,
|
||||
)
|
||||
const hasFullDayConflict = existingBookings.some(b => b.isFullDay && b.status !== 'cancelled')
|
||||
|
||||
const canSave = guestName.trim() !== '' && hours > 0 && maxHoursOk && !isTimeConflict && !hasFullDayConflict
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return
|
||||
onSave({
|
||||
id: `rb-${Date.now()}`,
|
||||
objectId: obj.id,
|
||||
date,
|
||||
isFullDay,
|
||||
startHour: isFullDay ? obj.openHour : startHour,
|
||||
endHour: isFullDay ? obj.closeHour : endHour,
|
||||
guestName: guestName.trim(),
|
||||
guestPhone: guestPhone.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
totalAmount,
|
||||
status: 'confirmed',
|
||||
})
|
||||
}
|
||||
|
||||
const displayDate = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', weekday: 'short' }).format(new Date(date))
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<span className="text-2xl">{obj.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">{obj.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">{displayDate}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost p-1.5">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-5 space-y-4">
|
||||
{/* Existing bookings for the day */}
|
||||
{existingBookings.filter(b => b.status !== 'cancelled').length > 0 && (
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-3 space-y-1.5">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Уже забронировано</p>
|
||||
{existingBookings.filter(b => b.status !== 'cancelled').map(b => (
|
||||
<div key={b.id} className="flex items-center gap-2 text-sm">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', obj.color)} />
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium">
|
||||
{b.isFullDay ? 'Весь день' : `${b.startHour}:00 – ${b.endHour}:00`}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFullDayConflict && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
||||
<AlertCircle size={15} className="shrink-0" />
|
||||
<span>Объект уже забронирован на весь день</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full day toggle */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600">
|
||||
<CalendarDays size={16} className="text-slate-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span>
|
||||
<button
|
||||
onClick={() => setIsFullDay(v => !v)}
|
||||
className={cn(
|
||||
'relative w-10 h-5.5 rounded-full transition-colors shrink-0',
|
||||
isFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600',
|
||||
)}
|
||||
style={{ height: 22, width: 40 }}
|
||||
>
|
||||
<span className={cn(
|
||||
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform',
|
||||
isFullDay ? 'translate-x-5' : 'translate-x-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time selection */}
|
||||
{!isFullDay && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Время аренды ({obj.openHour}:00 – {obj.closeHour}:00)
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-slate-500 mb-1">Начало</label>
|
||||
<select
|
||||
className="input text-sm"
|
||||
value={startHour}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
setStartHour(v)
|
||||
if (endHour <= v) setEndHour(Math.min(v + 1, obj.closeHour))
|
||||
}}
|
||||
>
|
||||
{hourOptions(obj.openHour, obj.closeHour - 1).map(h => (
|
||||
<option key={h} value={h}>{formatHour(h)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-slate-500 mb-1">Конец</label>
|
||||
<select
|
||||
className="input text-sm"
|
||||
value={endHour}
|
||||
onChange={e => setEndHour(parseInt(e.target.value))}
|
||||
>
|
||||
{hourOptions(startHour + 1, obj.closeHour).map(h => (
|
||||
<option key={h} value={h}>{formatHour(h)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{obj.maxHoursPerSlot && !maxHoursOk && (
|
||||
<p className="mt-1.5 text-xs text-red-500">
|
||||
Максимальное время бронирования: {obj.maxHoursPerSlot} ч
|
||||
</p>
|
||||
)}
|
||||
{isTimeConflict && (
|
||||
<p className="mt-1.5 text-xs text-red-500">
|
||||
Выбранное время пересекается с существующей бронью
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guest info */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Гость *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-8 text-sm"
|
||||
placeholder="Имя и фамилия"
|
||||
value={guestName}
|
||||
onChange={e => setGuestName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Телефон
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="tel"
|
||||
className="input pl-8 text-sm"
|
||||
placeholder="+7 999 000 11 22"
|
||||
value={guestPhone}
|
||||
onChange={e => setGuestPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Примечание
|
||||
</label>
|
||||
<textarea
|
||||
className="input resize-none text-sm"
|
||||
rows={2}
|
||||
placeholder="Дополнительно..."
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price summary */}
|
||||
<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">
|
||||
{isFullDay
|
||||
? `Весь день (${obj.openHour}:00 – ${obj.closeHour}:00)`
|
||||
: `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')} ₽`
|
||||
}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{totalAmount.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Забронировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user