From 57c22be60d3079c63f7681a799e70ac13317877d Mon Sep 17 00:00:00 2001
From: HotelSync
Date: Wed, 11 Mar 2026 17:08:10 +0300
Subject: [PATCH] Add floor map, rental module wiring, VIP tags and payment
tracking in BookingModal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
src/App.tsx | 2 +
src/components/bookings/BookingModal.tsx | 494 ++++++++++++-------
src/components/calendar/BookingCalendar.tsx | 142 +++++-
src/components/floormap/FloorMap.tsx | 213 ++++++++
src/components/floormap/FloorMapModal.tsx | 39 ++
src/components/layout/Sidebar.tsx | 3 +-
src/components/rental/RentalBookingModal.tsx | 250 ++++++++++
src/data/rentalData.ts | 134 +++++
src/pages/BookingsPage.tsx | 354 ++++++++-----
src/pages/CalendarPage.tsx | 14 +
src/pages/FloorMapPage.tsx | 84 ++++
11 files changed, 1432 insertions(+), 297 deletions(-)
create mode 100644 src/components/floormap/FloorMap.tsx
create mode 100644 src/components/floormap/FloorMapModal.tsx
create mode 100644 src/components/rental/RentalBookingModal.tsx
create mode 100644 src/data/rentalData.ts
create mode 100644 src/pages/FloorMapPage.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 5c7da9c..26b24d9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,6 +16,7 @@ import { ReportsPage } from './pages/ReportsPage'
import { WebsitePage } from './pages/WebsitePage'
import { BookingWidgetPage } from './pages/BookingWidgetPage'
import { AvailabilityPage } from './pages/AvailabilityPage'
+import { FloorMapPage } from './pages/FloorMapPage'
import { AdminDashboard } from './pages/AdminDashboard'
export default function App() {
@@ -43,6 +44,7 @@ export default function App() {
} />
} />
} />
+ } />
{/* Admin routes */}
diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx
index 0f2fece..bc3d3f8 100644
--- a/src/components/bookings/BookingModal.tsx
+++ b/src/components/bookings/BookingModal.tsx
@@ -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([])
+ 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: 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 (
-
-
-
- >
- }
- >
-
- {/* Room */}
-
-
-
-
-
- {/* Guest */}
-
+ <>
+
+
+
+ >
+ }
+ >
+
-
- {/* Dates */}
-
-
- {/* Guests count */}
-
-
-
- set('adults', parseInt(e.target.value) || 1)}
- />
-
-
-
- set('children', parseInt(e.target.value) || 0)}
- />
-
-
-
+
+
+
+
-
- {/* Source */}
-
-
-
- {(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => (
-
- {/* Notes */}
-
-
-
-
- {/* Summary */}
- {nights > 0 && room && (
-
-
- {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
-
-
- {total.toLocaleString('ru-RU')} ₽
-
+ {/* Source */}
+
+
+
+ {(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => (
+ 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}
+
+ ))}
+
- )}
-
-
+
+ {/* Payment */}
+ {nights > 0 && total > 0 && (
+
+
+
+ {/* Payment method */}
+
+
+
+ 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',
+ )}
+ >
+
+ Наличные
+
+ 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',
+ )}
+ >
+
+ Терминал
+
+
+
+
+ {/* Amount paid */}
+
+
+ setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
+ />
+
+
+
+ )}
+
+ {/* Notes */}
+
+
+
+
+ {/* Summary */}
+ {nights > 0 && room && (
+
+
+
+ {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
+
+
+ {total.toLocaleString('ru-RU')} ₽
+
+
+ {paidAmount > 0 && (
+
+ Оплачено
+
+ {paidAmount.toLocaleString('ru-RU')} ₽
+
+
+ )}
+ {debt > 0 && (
+
+
+
{isFutureBooking ? 'Задолженность' : 'Долг при заселении'}
+
{debt.toLocaleString('ru-RU')} ₽
+
+ )}
+
+ )}
+
+
+
+ {showFloorMap && (
+
{ set('roomId', id); setShowFloorMap(false) }}
+ onClose={() => setShowFloorMap(false)}
+ />
+ )}
+ >
)
}
diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx
index e71f305..a8e69ec 100644
--- a/src/components/calendar/BookingCalendar.tsx
+++ b/src/components/calendar/BookingCalendar.tsx
@@ -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) => void
onBookingUpdate: (id: string, b: Partial) => void
fadingBookingIds?: Set
+ 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(null)
const [selectedBooking, setSelectedBooking] = useState(null)
+ const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null)
const gridRef = useRef(null)
@@ -370,6 +376,124 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
)
})}
+
+ {/* ── Rental section ── */}
+ {rentalObjects && rentalObjects.length > 0 && (
+ <>
+ {/* Separator */}
+
+
+
+ Аренда объектов
+
+
+ {dates.map((_, i) => (
+
+ ))}
+
+
+ {/* Rental object rows */}
+ {rentalObjects.map(obj => {
+ const totalSpan = obj.closeHour - obj.openHour
+ return (
+
+ {/* Label */}
+
+
{obj.icon}
+
+
{obj.name}
+
+ {obj.pricePerHour.toLocaleString('ru-RU')} ₽/ч · {obj.pricePerDay.toLocaleString('ru-RU')} ₽/день
+
+
+
+
+ {/* 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 (
+
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 */
+
+ Весь день
+
+ ) : dayBookings.length > 0 ? (
+ /* Hourly booking bars */
+
+ {/* Time scale bar (background) */}
+
+ {dayBookings.map(b => {
+ const leftPct = ((b.startHour - obj.openHour) / totalSpan) * 100
+ const widthPct = ((b.endHour - b.startHour) / totalSpan) * 100
+ return (
+
+ )
+ })}
+
+ {/* Hour labels */}
+
+ {obj.openHour}:00
+ {obj.closeHour}:00
+
+ {/* Booking count */}
+
+ {dayBookings.map(b => (
+
+ {b.startHour}–{b.endHour}
+
+ ))}
+
+
+ ) : (
+ /* Empty — show "+" hint on hover */
+
+ +
+
+ )}
+
+ )
+ })}
+
+ )
+ })}
+ >
+ )}
@@ -399,6 +523,22 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
}}
/>
)}
+
+ {/* Rental booking modal */}
+ {rentalModal && (
+ b.objectId === rentalModal.obj.id && b.date === rentalModal.date,
+ )}
+ onClose={() => setRentalModal(null)}
+ onSave={(b) => {
+ onRentalBookingCreate?.(b)
+ setRentalModal(null)
+ }}
+ />
+ )}
)
}
diff --git a/src/components/floormap/FloorMap.tsx b/src/components/floormap/FloorMap.tsx
new file mode 100644
index 0000000..96906a2
--- /dev/null
+++ b/src/components/floormap/FloorMap.tsx
@@ -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 = {
+ 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(null)
+ const [hoveredRoom, setHoveredRoom] = useState(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 (
+
+ {/* Floor tabs */}
+
+ {floors.map(floor => {
+ const stats = floorStats(floor)
+ return (
+ 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',
+ )}
+ >
+ {floor} этаж
+
+ {stats.occupied}/{stats.total} занято
+
+
+ )
+ })}
+
+
+ {/* Floor plan */}
+
+ {/* Corridor bar */}
+
+ Коридор
+
+
+ {/* Room grid */}
+
+ {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 (
+
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 */}
+
+
+ {/* Room icon */}
+
+ {room.floorStatus === 'maintenance' ? (
+
+ ) : room.floorStatus === 'blocked' ? (
+
+ ) : room.floorStatus === 'occupied' || room.floorStatus === 'arriving' || room.floorStatus === 'departing' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Room number */}
+
+ {room.name ?? `№${room.number}`}
+
+
{room.type}
+
+ {/* Status label */}
+
+ {cfg.label}
+
+
+ {/* Guest name if occupied */}
+ {room.activeBooking && (isHovered || isSelected) && (
+
+
+ {room.activeBooking.guestName}
+
+ {room.floorStatus === 'departing' && (
+
Выезд сегодня
+ )}
+
+ )}
+
+ {/* Selected checkmark */}
+ {isSelected && (
+
+
+
+ )}
+
+ )
+ })}
+
+
+ {/* Stairwell / elevator hint */}
+
+
+ Лифт
+
+
+ Лестн.
+
+
+
+
+ {/* Legend */}
+
+ {LEGEND.map(status => {
+ const cfg = STATUS_CONFIG[status]
+ return (
+
+
+ {cfg.label}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/floormap/FloorMapModal.tsx b/src/components/floormap/FloorMapModal.tsx
new file mode 100644
index 0000000..527e857
--- /dev/null
+++ b/src/components/floormap/FloorMapModal.tsx
@@ -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 (
+
+
+
+
+
Поэтажный план
+ {onSelectRoom && (
+
Выберите номер для бронирования
+ )}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 8e8a180..dec79ff 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -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) {
Управление
+
{isModuleActive('channel-manager') && (
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 (
+
+
+ {/* Header */}
+
+
{obj.icon}
+
+
{obj.name}
+
{displayDate}
+
+
+
+
+
+
+
+ {/* Existing bookings for the day */}
+ {existingBookings.filter(b => b.status !== 'cancelled').length > 0 && (
+
+
Уже забронировано
+ {existingBookings.filter(b => b.status !== 'cancelled').map(b => (
+
+
+
+ {b.isFullDay ? 'Весь день' : `${b.startHour}:00 – ${b.endHour}:00`}
+
+
{b.guestName}
+
+ ))}
+
+ )}
+
+ {hasFullDayConflict && (
+
+
+
Объект уже забронирован на весь день
+
+ )}
+
+ {/* Full day toggle */}
+
+
+ Весь день
+ 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 }}
+ >
+
+
+
+
+ {/* Time selection */}
+ {!isFullDay && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {obj.maxHoursPerSlot && !maxHoursOk && (
+
+ Максимальное время бронирования: {obj.maxHoursPerSlot} ч
+
+ )}
+ {isTimeConflict && (
+
+ Выбранное время пересекается с существующей бронью
+
+ )}
+
+ )}
+
+ {/* Guest info */}
+
+
+
+
+
+ setGuestName(e.target.value)}
+ />
+
+
+
+
+
+
+
setGuestPhone(e.target.value)}
+ />
+
+
+
+
+
+
+
+ {/* Price summary */}
+
+
+ {isFullDay
+ ? `Весь день (${obj.openHour}:00 – ${obj.closeHour}:00)`
+ : `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')} ₽`
+ }
+
+
+ {totalAmount.toLocaleString('ru-RU')} ₽
+
+
+
+
+ {/* Footer */}
+
+ Отмена
+
+ Забронировать
+
+
+
+
+ )
+}
diff --git a/src/data/rentalData.ts b/src/data/rentalData.ts
new file mode 100644
index 0000000..862d87b
--- /dev/null
+++ b/src/data/rentalData.ts
@@ -0,0 +1,134 @@
+import { addDays, format } from 'date-fns'
+
+export interface RentalObject {
+ id: string
+ name: string
+ icon: string
+ color: string // tailwind bg class
+ textColor: string // tailwind text class
+ pricePerHour: number
+ pricePerDay: number
+ openHour: number // 8
+ closeHour: number // 22
+ maxHoursPerSlot?: number
+}
+
+export interface RentalBooking {
+ id: string
+ objectId: string
+ date: string // 'yyyy-MM-dd'
+ isFullDay: boolean
+ startHour: number // 10 (ignored if isFullDay)
+ endHour: number // 12 (ignored if isFullDay)
+ guestName: string
+ guestPhone: string
+ linkedRoomId?: string
+ totalAmount: number
+ status: 'confirmed' | 'cancelled'
+ notes?: string
+}
+
+export const RENTAL_OBJECTS: RentalObject[] = [
+ {
+ id: 'court',
+ name: 'Теннисный корт',
+ icon: '🎾',
+ color: 'bg-green-500',
+ textColor: 'text-green-700 dark:text-green-400',
+ pricePerHour: 1500,
+ pricePerDay: 8000,
+ openHour: 8,
+ closeHour: 22,
+ },
+ {
+ id: 'sauna',
+ name: 'Баня',
+ icon: '🛁',
+ color: 'bg-orange-500',
+ textColor: 'text-orange-700 dark:text-orange-400',
+ pricePerHour: 2500,
+ pricePerDay: 12000,
+ openHour: 10,
+ closeHour: 23,
+ maxHoursPerSlot: 4,
+ },
+ {
+ id: 'hall',
+ name: 'Конференц-зал',
+ icon: '🏛️',
+ color: 'bg-violet-500',
+ textColor: 'text-violet-700 dark:text-violet-400',
+ pricePerHour: 3000,
+ pricePerDay: 15000,
+ openHour: 9,
+ closeHour: 20,
+ },
+]
+
+function todayPlus(days: number): string {
+ return format(addDays(new Date(), days), 'yyyy-MM-dd')
+}
+
+export const MOCK_RENTAL_BOOKINGS: RentalBooking[] = [
+ {
+ id: 'rb1',
+ objectId: 'court',
+ date: todayPlus(0),
+ isFullDay: false,
+ startHour: 10,
+ endHour: 12,
+ guestName: 'Иванов А.П.',
+ guestPhone: '+7 999 111 22 33',
+ totalAmount: 3000,
+ status: 'confirmed',
+ },
+ {
+ id: 'rb2',
+ objectId: 'court',
+ date: todayPlus(0),
+ isFullDay: false,
+ startHour: 15,
+ endHour: 17,
+ guestName: 'Петрова М.С.',
+ guestPhone: '+7 912 345 67 89',
+ totalAmount: 3000,
+ status: 'confirmed',
+ },
+ {
+ id: 'rb3',
+ objectId: 'sauna',
+ date: todayPlus(1),
+ isFullDay: false,
+ startHour: 18,
+ endHour: 21,
+ guestName: 'Сидоров К.В.',
+ guestPhone: '+7 926 555 44 33',
+ linkedRoomId: 'r2',
+ totalAmount: 7500,
+ status: 'confirmed',
+ },
+ {
+ id: 'rb4',
+ objectId: 'hall',
+ date: todayPlus(2),
+ isFullDay: true,
+ startHour: 9,
+ endHour: 20,
+ guestName: 'ООО "Конференция"',
+ guestPhone: '+7 495 000 11 22',
+ totalAmount: 15000,
+ status: 'confirmed',
+ },
+ {
+ id: 'rb5',
+ objectId: 'court',
+ date: todayPlus(3),
+ isFullDay: false,
+ startHour: 9,
+ endHour: 11,
+ guestName: 'Козлов Д.Р.',
+ guestPhone: '+7 900 222 33 44',
+ totalAmount: 3000,
+ status: 'confirmed',
+ },
+]
diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx
index 1f0a88c..cfa87ca 100644
--- a/src/pages/BookingsPage.tsx
+++ b/src/pages/BookingsPage.tsx
@@ -1,7 +1,10 @@
import { useState, useMemo } from 'react'
-import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
+import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock } from 'lucide-react'
import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData'
+import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
+import { useModules } from '../contexts/ModulesContext'
import type { Booking, BookingStatus } from '../types'
+import type { RentalBooking } from '../data/rentalData'
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { BookingModal } from '../components/bookings/BookingModal'
@@ -30,8 +33,15 @@ const COLUMNS: { key: SortKey | null; label: string }[] = [
{ key: null, label: '' },
]
+type Tab = 'rooms' | 'rental'
+
export function BookingsPage() {
+ const { statuses } = useModules()
+ const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
+
+ const [tab, setTab] = useState('rooms')
const [bookings, setBookings] = useState(MOCK_BOOKINGS)
+ const [rentalBookings] = useState(MOCK_RENTAL_BOOKINGS)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const [selected, setSelected] = useState(null)
@@ -68,7 +78,14 @@ export function BookingsPage() {
})
}, [filtered, sortKey, sortDir])
+ const filteredRental = rentalBookings.filter(b => {
+ if (search === '') return true
+ return b.guestName.toLowerCase().includes(search.toLowerCase()) ||
+ b.guestPhone.includes(search)
+ })
+
const room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
+ const rentalObj = (id: string) => RENTAL_OBJECTS.find(o => o.id === id)
return (
@@ -76,140 +93,241 @@ export function BookingsPage() {
Бронирования
-
{sorted.length} из {bookings.length}
+
+ {tab === 'rooms' ? `${sorted.length} из ${bookings.length}` : `${filteredRental.length} аренд`}
+
-
setShowCreateModal(true)}
- className="btn-primary"
- >
-
- Новое
-
+ {tab === 'rooms' && (
+
setShowCreateModal(true)} className="btn-primary">
+
+ Новое
+
+ )}
- {/* Filters */}
+ {/* Tabs */}
+ {isRentalActive && (
+
+ setTab('rooms')}
+ className={cn(
+ 'px-4 py-1.5 rounded-lg text-sm font-medium transition-colors',
+ tab === 'rooms'
+ ? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
+ : 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
+ )}
+ >
+ Номера
+
+ setTab('rental')}
+ className={cn(
+ 'px-4 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5',
+ tab === 'rental'
+ ? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
+ : 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
+ )}
+ >
+
+ Аренда
+
+
+ )}
+
+ {/* Search */}
setSearch(e.target.value)}
/>
-
- {STATUS_FILTERS.map(f => (
- setStatusFilter(f.value)}
- className={cn(
- 'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
- statusFilter === f.value
- ? 'bg-brand-600 text-white border-brand-600'
- : 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
- )}
- >
- {f.label}
-
- ))}
-
+ {tab === 'rooms' && (
+
+ {STATUS_FILTERS.map(f => (
+ setStatusFilter(f.value)}
+ className={cn(
+ 'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
+ statusFilter === f.value
+ ? 'bg-brand-600 text-white border-brand-600'
+ : 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
+ )}
+ >
+ {f.label}
+
+ ))}
+
+ )}
- {/* Table */}
-
-
-
-
-
- {COLUMNS.map(col => (
- |
- {col.key ? (
- handleSort(col.key!)}
- className="flex items-center gap-1 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
- >
- {col.label}
- {sortKey === col.key
- ? sortDir === 'asc'
- ?
- :
- :
- }
-
- ) : col.label}
- |
- ))}
-
-
-
- {sorted.map(b => {
- const r = room(b.roomId)
- const nights = nightsCount(b.checkIn, b.checkOut)
- return (
- setSelected(b)}
- >
- |
- {b.guestName}
- {b.guestEmail}
- |
-
-
- {r ? `№${r.number}` : '—'}
-
- {r?.type}
- |
-
- {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkIn))}
- |
-
- {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkOut))}
- {nights}н
- |
-
-
- {BOOKING_STATUS_LABELS[b.status]}
-
- |
-
-
- {SOURCE_LABELS[b.source]}
-
- |
-
-
- {formatCurrency(b.totalAmount)}
-
- {b.paidAmount < b.totalAmount && (
-
- -{formatCurrency(b.totalAmount - b.paidAmount)}
+ {/* Room bookings table */}
+ {tab === 'rooms' && (
+
+
+
+
+
+ {COLUMNS.map(col => (
+ |
+ {col.key ? (
+ handleSort(col.key!)}
+ className="flex items-center gap-1 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
+ >
+ {col.label}
+ {sortKey === col.key
+ ? sortDir === 'asc'
+ ?
+ :
+ :
+ }
+
+ ) : col.label}
+ |
+ ))}
+
+
+
+ {sorted.map(b => {
+ const r = room(b.roomId)
+ const nights = nightsCount(b.checkIn, b.checkOut)
+ return (
+ setSelected(b)}
+ >
+ |
+ {b.guestName}
+ {b.guestEmail}
+ |
+
+
+ {r ? `№${r.number}` : '—'}
- )}
- |
-
-
- Открыть →
-
- |
-
- )
- })}
-
-
- {sorted.length === 0 && (
-
- Бронирования не найдены
-
- )}
+ {r?.type}
+ |
+
+ {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkIn))}
+ |
+
+ {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkOut))}
+ {nights}н
+ |
+
+
+ {BOOKING_STATUS_LABELS[b.status]}
+
+ |
+
+
+ {SOURCE_LABELS[b.source]}
+
+ |
+
+
+ {formatCurrency(b.totalAmount)}
+
+ {b.paidAmount < b.totalAmount && (
+
+ -{formatCurrency(b.totalAmount - b.paidAmount)}
+
+ )}
+ |
+
+
+ Открыть →
+
+ |
+
+ )
+ })}
+
+
+ {sorted.length === 0 && (
+
+ Бронирования не найдены
+
+ )}
+
-
+ )}
+
+ {/* Rental bookings table */}
+ {tab === 'rental' && (
+
+
+
+
+
+ {[
+ 'Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус',
+ ].map(h => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {filteredRental.map(b => {
+ const obj = rentalObj(b.objectId)
+ return (
+
+ |
+ {b.guestName}
+ {b.guestPhone}
+ |
+
+ {obj && (
+
+ {obj.icon}
+ {obj.name}
+
+ )}
+ |
+
+ {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', weekday: 'short' }).format(new Date(b.date))}
+ |
+
+ {b.isFullDay
+ ? Весь день
+ : `${b.startHour}:00 – ${b.endHour}:00`
+ }
+ |
+
+ {formatCurrency(b.totalAmount)}
+ |
+
+
+ {b.status === 'confirmed' ? 'Подтверждено' : 'Отменено'}
+
+ |
+
+ )
+ })}
+
+
+ {filteredRental.length === 0 && (
+
+ Записи об аренде не найдены
+
+ )}
+
+
+ )}
{/* Create modal */}
{showCreateModal && (
diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx
index 2268088..3c23450 100644
--- a/src/pages/CalendarPage.tsx
+++ b/src/pages/CalendarPage.tsx
@@ -1,11 +1,18 @@
import { useState } from 'react'
import { BookingCalendar } from '../components/calendar/BookingCalendar'
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
+import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
+import { useModules } from '../contexts/ModulesContext'
import type { Booking } from '../types'
+import type { RentalBooking } from '../data/rentalData'
export function CalendarPage() {
+ const { statuses } = useModules()
+ const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
+
const [bookings, setBookings] = useState(MOCK_BOOKINGS)
const [fadingBookings, setFadingBookings] = useState>(new Set())
+ const [rentalBookings, setRentalBookings] = useState(MOCK_RENTAL_BOOKINGS)
const handleCreate = (data: Partial) => {
setBookings(prev => [...prev, data as Booking])
@@ -26,6 +33,10 @@ export function CalendarPage() {
}
}
+ const handleRentalCreate = (b: RentalBooking) => {
+ setRentalBookings(prev => [...prev, b])
+ }
+
return (
@@ -43,6 +54,9 @@ export function CalendarPage() {
onBookingCreate={handleCreate}
onBookingUpdate={handleUpdate}
fadingBookingIds={fadingBookings}
+ rentalObjects={isRentalActive ? RENTAL_OBJECTS : undefined}
+ rentalBookings={isRentalActive ? rentalBookings : undefined}
+ onRentalBookingCreate={isRentalActive ? handleRentalCreate : undefined}
/>
diff --git a/src/pages/FloorMapPage.tsx b/src/pages/FloorMapPage.tsx
new file mode 100644
index 0000000..cd9b842
--- /dev/null
+++ b/src/pages/FloorMapPage.tsx
@@ -0,0 +1,84 @@
+import { useState } from 'react'
+import { format, addDays } from 'date-fns'
+import { RefreshCw } from 'lucide-react'
+import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
+import { FloorMap } from '../components/floormap/FloorMap'
+import { BookingModal } from '../components/bookings/BookingModal'
+import type { Booking } from '../types'
+
+export function FloorMapPage() {
+ const [bookings, setBookings] = useState(MOCK_BOOKINGS)
+ const [createForRoom, setCreateForRoom] = useState(null)
+ const [lastUpdate] = useState(new Date())
+
+ const handleSelectRoom = (id: string) => {
+ setCreateForRoom(id)
+ }
+
+ const stats = {
+ total: MOCK_ROOMS.length,
+ occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length,
+ available: MOCK_ROOMS.filter(r => r.status === 'available').length,
+ maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance' || r.status === 'blocked').length,
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Поэтажный план
+
+ Обновлено: {format(lastUpdate, 'HH:mm')} · Нажмите на свободный номер чтобы создать бронирование
+
+
+
+
+ Обновить
+
+
+
+ {/* Quick stats */}
+
+ {[
+ { label: 'Всего номеров', value: stats.total, color: 'text-slate-900 dark:text-slate-100' },
+ { label: 'Занято', value: stats.occupied, color: 'text-red-600 dark:text-red-400' },
+ { label: 'Свободно', value: stats.available, color: 'text-emerald-600 dark:text-emerald-400' },
+ { label: 'Ремонт/закрыт', value: stats.maintenance, color: 'text-slate-500 dark:text-slate-400' },
+ ].map(s => (
+
+
{s.label}
+
{s.value}
+
+ ))}
+
+
+ {/* Floor map */}
+
+
+
+
+ {/* Create booking modal */}
+ {createForRoom && (
+
setCreateForRoom(null)}
+ onSave={(data) => {
+ setBookings(prev => [...prev, data as Booking])
+ setCreateForRoom(null)
+ }}
+ />
+ )}
+
+ )
+}