Add floor map, rental module wiring, VIP tags and payment tracking in BookingModal

- New: Поэтажный план (/floor-map) — room status visualization per floor with color coding
  (occupied/arriving/departing/available/dirty/maintenance), click free room to create booking
- New: FloorMapModal — embeddable in BookingModal via "Поэтажный план" button near room selector
- New: RentalBookingModal — full-day toggle, hour start/end selects, conflict detection, price summary
- CalendarPage: rental objects/bookings shown in шахматка when rental module is active
- BookingsPage: "Аренда" tab with rental bookings table when rental module is active
- BookingModal: guest tags (VIP, Постоянный гость, etc.), payment method (cash/terminal),
  paidAmount input, debt/задолженность display, floor map quick-access button
- Sidebar: "План этажей" nav link added under Управление

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 17:08:10 +03:00
parent 74e0223c3c
commit 57c22be60d
11 changed files with 1432 additions and 297 deletions

View File

@@ -16,6 +16,7 @@ import { ReportsPage } from './pages/ReportsPage'
import { WebsitePage } from './pages/WebsitePage' import { WebsitePage } from './pages/WebsitePage'
import { BookingWidgetPage } from './pages/BookingWidgetPage' import { BookingWidgetPage } from './pages/BookingWidgetPage'
import { AvailabilityPage } from './pages/AvailabilityPage' import { AvailabilityPage } from './pages/AvailabilityPage'
import { FloorMapPage } from './pages/FloorMapPage'
import { AdminDashboard } from './pages/AdminDashboard' import { AdminDashboard } from './pages/AdminDashboard'
export default function App() { export default function App() {
@@ -43,6 +44,7 @@ export default function App() {
<Route path="/website" element={<WebsitePage />} /> <Route path="/website" element={<WebsitePage />} />
<Route path="/booking-widget" element={<BookingWidgetPage />} /> <Route path="/booking-widget" element={<BookingWidgetPage />} />
<Route path="/availability" element={<AvailabilityPage />} /> <Route path="/availability" element={<AvailabilityPage />} />
<Route path="/floor-map" element={<FloorMapPage />} />
</Route> </Route>
{/* Admin routes */} {/* Admin routes */}

View File

@@ -1,8 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map } from 'lucide-react'
import { Modal } from '../ui/Modal' import { Modal } from '../ui/Modal'
import { cn, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils' import { cn, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
import type { Booking, DraftBooking, Room, BookingStatus, BookingSource } from '../../types' import type { Booking, DraftBooking, Room, BookingStatus, BookingSource } from '../../types'
import { FloorMapModal } from '../floormap/FloorMapModal'
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
interface BookingModalProps { interface BookingModalProps {
open: boolean open: boolean
@@ -26,22 +30,31 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
status: (existing?.status ?? 'confirmed') as BookingStatus, status: (existing?.status ?? 'confirmed') as BookingStatus,
notes: existing?.notes ?? '', 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 room = rooms.find(r => r.id === form.roomId)
const nights = form.checkIn && form.checkOut const nights = form.checkIn && form.checkOut
? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000) ? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000)
: 0 : 0
const total = (room?.baseRate ?? 0) * nights 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]) => const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v })) setForm(prev => ({ ...prev, [k]: v }))
const toggleTag = (tag: string) =>
setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
const handleSave = () => { const handleSave = () => {
if (!form.guestName || !form.checkIn || !form.checkOut) return if (!form.guestName || !form.checkIn || !form.checkOut) return
onSave({ onSave({
...form, ...form,
totalAmount: total, totalAmount: total,
paidAmount: existing?.paidAmount ?? 0, paidAmount,
id: existing?.id ?? `b-${Date.now()}`, id: existing?.id ?? `b-${Date.now()}`,
hotelId: 'hotel-1', hotelId: 'hotel-1',
guestId: existing?.guestId ?? `g-${Date.now()}`, guestId: existing?.guestId ?? `g-${Date.now()}`,
@@ -50,6 +63,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
} }
return ( return (
<>
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
@@ -67,9 +81,19 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
<div className="space-y-4"> <div className="space-y-4">
{/* Room */} {/* Room */}
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> <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> </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 <select
value={form.roomId} value={form.roomId}
onChange={e => set('roomId', e.target.value)} onChange={e => set('roomId', e.target.value)}
@@ -111,6 +135,37 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</div> </div>
</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 */} {/* Dates */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
@@ -201,6 +256,64 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</div> </div>
</div> </div>
{/* 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 */} {/* Notes */}
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
@@ -217,7 +330,8 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
{/* Summary */} {/* Summary */}
{nights > 0 && room && ( {nights > 0 && room && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex items-center justify-between"> <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"> <span className="text-sm text-slate-600 dark:text-slate-300">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')}
</span> </span>
@@ -225,8 +339,34 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
{total.toLocaleString('ru-RU')} {total.toLocaleString('ru-RU')}
</span> </span>
</div> </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> </div>
</Modal> </Modal>
{showFloorMap && (
<FloorMapModal
rooms={rooms}
selectedRoomId={form.roomId}
onSelectRoom={(id) => { set('roomId', id); setShowFloorMap(false) }}
onClose={() => setShowFloorMap(false)}
/>
)}
</>
) )
} }

View File

@@ -4,8 +4,10 @@ import { ru } from 'date-fns/locale'
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } from 'lucide-react' import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } from 'lucide-react'
import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils' import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
import type { Room, Booking, DraftBooking } from '../../types' import type { Room, Booking, DraftBooking } from '../../types'
import type { RentalObject, RentalBooking } from '../../data/rentalData'
import { BookingModal } from '../bookings/BookingModal' import { BookingModal } from '../bookings/BookingModal'
import { BookingDetailPanel } from '../bookings/BookingDetailPanel' import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
import { RentalBookingModal } from '../rental/RentalBookingModal'
const CELL_WIDTH = 52 const CELL_WIDTH = 52
const ROW_HEIGHT = 56 const ROW_HEIGHT = 56
@@ -18,6 +20,9 @@ interface BookingCalendarProps {
onBookingCreate: (b: Partial<Booking>) => void onBookingCreate: (b: Partial<Booking>) => void
onBookingUpdate: (id: string, b: Partial<Booking>) => void onBookingUpdate: (id: string, b: Partial<Booking>) => void
fadingBookingIds?: Set<string> fadingBookingIds?: Set<string>
rentalObjects?: RentalObject[]
rentalBookings?: RentalBooking[]
onRentalBookingCreate?: (b: RentalBooking) => void
} }
function getRoomTypeColor(type: string): string { 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' 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 [startDate, setStartDate] = useState(() => startOfDay(new Date()))
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE) const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
@@ -60,6 +65,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
// Modals // Modals
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null) const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null) const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null)
const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null)
const gridRef = useRef<HTMLDivElement>(null) const gridRef = useRef<HTMLDivElement>(null)
@@ -370,6 +376,124 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
</div> </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>
</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> </div>
) )
} }

View 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>
)
}

View 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>
)
}

View File

@@ -1,7 +1,7 @@
import { NavLink, useNavigate } from 'react-router-dom' import { NavLink, useNavigate } from 'react-router-dom'
import { import {
CalendarDays, BookOpen, BedDouble, Globe, Settings, 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' } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext' import { useModules } from '../../contexts/ModulesContext'
@@ -135,6 +135,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
Управление Управление
</p> </p>
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} /> <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} /> <NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
{isModuleActive('channel-manager') && ( {isModuleActive('channel-manager') && (
<NavItem <NavItem

View 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>
)
}

134
src/data/rentalData.ts Normal file
View File

@@ -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',
},
]

View File

@@ -1,7 +1,10 @@
import { useState, useMemo } from 'react' 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 { 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 { 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 { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
import { BookingModal } from '../components/bookings/BookingModal' import { BookingModal } from '../components/bookings/BookingModal'
@@ -30,8 +33,15 @@ const COLUMNS: { key: SortKey | null; label: string }[] = [
{ key: null, label: '' }, { key: null, label: '' },
] ]
type Tab = 'rooms' | 'rental'
export function BookingsPage() { export function BookingsPage() {
const { statuses } = useModules()
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
const [tab, setTab] = useState<Tab>('rooms')
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS) const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [rentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all') const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all')
const [selected, setSelected] = useState<Booking | null>(null) const [selected, setSelected] = useState<Booking | null>(null)
@@ -68,7 +78,14 @@ export function BookingsPage() {
}) })
}, [filtered, sortKey, sortDir]) }, [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 room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
const rentalObj = (id: string) => RENTAL_OBJECTS.find(o => o.id === id)
return ( return (
<div className="p-4 md:p-6 space-y-4"> <div className="p-4 md:p-6 space-y-4">
@@ -76,29 +93,60 @@ export function BookingsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Бронирования</h1> <h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Бронирования</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{sorted.length} из {bookings.length}</p> <p className="text-sm text-slate-500 dark:text-slate-400">
{tab === 'rooms' ? `${sorted.length} из ${bookings.length}` : `${filteredRental.length} аренд`}
</p>
</div> </div>
<button {tab === 'rooms' && (
onClick={() => setShowCreateModal(true)} <button onClick={() => setShowCreateModal(true)} className="btn-primary">
className="btn-primary"
>
<Plus size={15} /> <Plus size={15} />
<span className="hidden sm:inline">Новое</span> <span className="hidden sm:inline">Новое</span>
</button> </button>
)}
</div> </div>
{/* Filters */} {/* Tabs */}
{isRentalActive && (
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-fit">
<button
onClick={() => 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',
)}
>
Номера
</button>
<button
onClick={() => 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',
)}
>
<Clock size={13} />
Аренда
</button>
</div>
)}
{/* Search */}
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input <input
type="text" type="text"
className="input pl-9" className="input pl-9"
placeholder="Поиск по гостю, email, ID..." placeholder={tab === 'rooms' ? 'Поиск по гостю, email, ID...' : 'Поиск по гостю, телефону...'}
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
/> />
</div> </div>
{tab === 'rooms' && (
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">
{STATUS_FILTERS.map(f => ( {STATUS_FILTERS.map(f => (
<button <button
@@ -115,9 +163,11 @@ export function BookingsPage() {
</button> </button>
))} ))}
</div> </div>
)}
</div> </div>
{/* Table */} {/* Room bookings table */}
{tab === 'rooms' && (
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -210,6 +260,74 @@ export function BookingsPage() {
)} )}
</div> </div>
</div> </div>
)}
{/* Rental bookings table */}
{tab === 'rental' && (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
{[
'Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус',
].map(h => (
<th key={h} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filteredRental.map(b => {
const obj = rentalObj(b.objectId)
return (
<tr key={b.id} className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td className="px-4 py-3">
<p className="font-medium text-slate-900 dark:text-slate-100">{b.guestName}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{b.guestPhone}</p>
</td>
<td className="px-4 py-3">
{obj && (
<div className="flex items-center gap-1.5">
<span className="text-base">{obj.icon}</span>
<span className="text-slate-800 dark:text-slate-200 font-medium">{obj.name}</span>
</div>
)}
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', weekday: 'short' }).format(new Date(b.date))}
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
{b.isFullDay
? <span className="text-xs font-medium px-2 py-0.5 bg-slate-100 dark:bg-slate-700 rounded-full">Весь день</span>
: `${b.startHour}:00 ${b.endHour}:00`
}
</td>
<td className="px-4 py-3 font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(b.totalAmount)}
</td>
<td className="px-4 py-3">
<Badge className={b.status === 'confirmed'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
}>
{b.status === 'confirmed' ? 'Подтверждено' : 'Отменено'}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
{filteredRental.length === 0 && (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
Записи об аренде не найдены
</div>
)}
</div>
</div>
)}
{/* Create modal */} {/* Create modal */}
{showCreateModal && ( {showCreateModal && (

View File

@@ -1,11 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { BookingCalendar } from '../components/calendar/BookingCalendar' import { BookingCalendar } from '../components/calendar/BookingCalendar'
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData' 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 { Booking } from '../types'
import type { RentalBooking } from '../data/rentalData'
export function CalendarPage() { export function CalendarPage() {
const { statuses } = useModules()
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS) const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set()) const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
const handleCreate = (data: Partial<Booking>) => { const handleCreate = (data: Partial<Booking>) => {
setBookings(prev => [...prev, data as Booking]) setBookings(prev => [...prev, data as Booking])
@@ -26,6 +33,10 @@ export function CalendarPage() {
} }
} }
const handleRentalCreate = (b: RentalBooking) => {
setRentalBookings(prev => [...prev, b])
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0"> <div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0">
@@ -43,6 +54,9 @@ export function CalendarPage() {
onBookingCreate={handleCreate} onBookingCreate={handleCreate}
onBookingUpdate={handleUpdate} onBookingUpdate={handleUpdate}
fadingBookingIds={fadingBookings} fadingBookingIds={fadingBookings}
rentalObjects={isRentalActive ? RENTAL_OBJECTS : undefined}
rentalBookings={isRentalActive ? rentalBookings : undefined}
onRentalBookingCreate={isRentalActive ? handleRentalCreate : undefined}
/> />
</div> </div>
</div> </div>

View File

@@ -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<Booking[]>(MOCK_BOOKINGS)
const [createForRoom, setCreateForRoom] = useState<string | null>(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 (
<div className="p-4 md:p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Поэтажный план</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Обновлено: {format(lastUpdate, 'HH:mm')} · Нажмите на свободный номер чтобы создать бронирование
</p>
</div>
<button className="btn-secondary flex items-center gap-1.5">
<RefreshCw size={14} />
Обновить
</button>
</div>
{/* Quick stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ 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 => (
<div key={s.label} className="card px-4 py-3">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5">{s.label}</p>
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Floor map */}
<div className="card">
<FloorMap
rooms={MOCK_ROOMS}
bookings={bookings}
onSelectRoom={handleSelectRoom}
/>
</div>
{/* Create booking modal */}
{createForRoom && (
<BookingModal
open
draft={{
roomId: createForRoom,
checkIn: format(new Date(), 'yyyy-MM-dd'),
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
}}
rooms={MOCK_ROOMS}
onClose={() => setCreateForRoom(null)}
onSave={(data) => {
setBookings(prev => [...prev, data as Booking])
setCreateForRoom(null)
}}
/>
)}
</div>
)
}