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:
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